vtc-service 0.7.0

Service for Verifiable Trust Communities
// My passkeys plugin — list, register additional, revoke.
//
// Wraps the `/v1/admin/passkeys/*` endpoint family. Register is a
// dual-ceremony: a new-credential `create` plus a step-up UV
// `get` against an existing passkey in the same start/finish pair.
// Revoke is a single UV ceremony.

import { useState } from "react";
import {
  useMutation,
  useQuery,
  useQueryClient,
} from "@tanstack/react-query";
import { KeyRound, Plus, X } from "lucide-react";

import { getJson, postJson } from "@/lib/api";
import { useConfirm } from "@/components/ConfirmDialog";
import { formatIso as formatDate } from "@/lib/format";
import {
  decodePublicKeyOptions,
  serializeAssertion,
  serializeRegistration,
  type JsonPublicKeyOptions,
} from "@/lib/webauthn";

const TRUST_TASK_LIST =
  "https://trusttasks.org/openvtc/vtc/admin/passkeys/list/1.0";
const TRUST_TASK_REGISTER =
  "https://trusttasks.org/openvtc/vtc/admin/passkeys/register/1.0";
const TRUST_TASK_REVOKE =
  "https://trusttasks.org/openvtc/vtc/admin/passkeys/revoke/1.0";


interface RegisteredPasskey {
  credentialId: string;
  label: string;
  transports: string[];
  registeredAt: string;
  lastUsedAt: string | null;
}

interface ListResponse {
  passkeys: RegisteredPasskey[];
}

interface RegisterStartResponse {
  registrationId: string;
  registerOptions: { publicKey: JsonPublicKeyOptions };
  uvOptions: { publicKey: JsonPublicKeyOptions };
}

interface RevokeStartResponse {
  revocationId: string;
  uvOptions: { publicKey: JsonPublicKeyOptions };
}

async function fetchPasskeys(): Promise<ListResponse> {
  return getJson<ListResponse>("/v1/admin/passkeys", {
    trustTask: TRUST_TASK_LIST,
  });
}

async function registerPasskey(args: {
  label: string;
}): Promise<void> {
  // /register/start returns BOTH a create challenge (for the new
  // passkey) and a UV challenge (against existing passkeys, for
  // step-up). The browser runs both ceremonies, then /register/
  // finish takes both responses.
  const start = await postJson<RegisterStartResponse>(
    "/v1/admin/passkeys/register/start",
    undefined,
    { trustTask: TRUST_TASK_REGISTER },
  );

  const createPublicKey = decodePublicKeyOptions(
    start.registerOptions.publicKey,
  ) as PublicKeyCredentialCreationOptions;
  const newCred = (await navigator.credentials.create({
    publicKey: createPublicKey,
  })) as PublicKeyCredential | null;
  if (!newCred) {
    throw new Error("Passkey creation returned no credential");
  }

  const uvPublicKey = decodePublicKeyOptions(
    start.uvOptions.publicKey,
  ) as PublicKeyCredentialRequestOptions;
  const uvCred = (await navigator.credentials.get({
    publicKey: uvPublicKey,
  })) as PublicKeyCredential | null;
  if (!uvCred) {
    throw new Error("Step-up UV returned no credential");
  }

  await postJson<unknown>(
    "/v1/admin/passkeys/register/finish",
    {
      registration_id: start.registrationId,
      register_response: serializeRegistration(newCred),
      uv_response: serializeAssertion(uvCred),
      label: args.label,
      transports: [],
    },
    { trustTask: TRUST_TASK_REGISTER },
  );
}

async function revokePasskey(args: {
  credentialId: string;
}): Promise<void> {
  const start = await postJson<RevokeStartResponse>(
    "/v1/admin/passkeys/revoke/start",
    { credential_id: args.credentialId },
    { trustTask: TRUST_TASK_REVOKE },
  );

  const uvPublicKey = decodePublicKeyOptions(
    start.uvOptions.publicKey,
  ) as PublicKeyCredentialRequestOptions;
  const uvCred = (await navigator.credentials.get({
    publicKey: uvPublicKey,
  })) as PublicKeyCredential | null;
  if (!uvCred) {
    throw new Error("Step-up UV returned no credential");
  }

  await postJson<unknown>(
    "/v1/admin/passkeys/revoke/finish",
    {
      revocation_id: start.revocationId,
      uv_response: serializeAssertion(uvCred),
    },
    { trustTask: TRUST_TASK_REVOKE },
  );
}

export function MyPasskeys() {
  const queryClient = useQueryClient();
  const confirm = useConfirm();
  const [showRegister, setShowRegister] = useState(false);
  const [label, setLabel] = useState("");

  const query = useQuery({
    queryKey: ["my-passkeys"],
    queryFn: fetchPasskeys,
  });

  const registerMutation = useMutation({
    mutationFn: registerPasskey,
    onSuccess: () => {
      void queryClient.invalidateQueries({ queryKey: ["my-passkeys"] });
      setShowRegister(false);
      setLabel("");
    },
  });

  const revokeMutation = useMutation({
    mutationFn: revokePasskey,
    onSuccess: () => {
      void queryClient.invalidateQueries({ queryKey: ["my-passkeys"] });
    },
  });

  const passkeys = query.data?.passkeys ?? [];
  const onlyOne = passkeys.length === 1;

  return (
    <section className="page">
      <h2>My passkeys</h2>
      <p className="lead">
        Manage the passkeys bound to your admin DID. Register a
        backup before losing access to your primary device — losing
        your only passkey means using <code>vtc admin emergency-bootstrap</code>{" "}
        on the host to recover.
      </p>

      {query.error && (
        <section className="card error">
          <h3>Failed to load passkeys</h3>
          <p>{(query.error as Error).message}</p>
        </section>
      )}

      <section className="card">
        <div className="toolbar">
          <div className="spacer" />
          <button
            type="button"
            className={showRegister ? "secondary" : "primary"}
            onClick={() => setShowRegister((v) => !v)}
          >
            {showRegister ? (
              <>
                <X size={14} aria-hidden="true" /> Cancel
              </>
            ) : (
              <>
                <Plus size={14} aria-hidden="true" /> Register new passkey
              </>
            )}
          </button>
        </div>
      </section>

      {showRegister && (
        <section className="card">
          <h3>Register additional passkey</h3>
          <p className="lead">
            Your browser will prompt twice — once to create the new
            credential, then to verify your existing passkey.
          </p>
          <form
            onSubmit={(e) => {
              e.preventDefault();
              registerMutation.mutate({ label });
            }}
            className="form-stack"
          >
            <label className="field">
              <span className="field-label">Label</span>
              <input
                type="text"
                placeholder="e.g. ‘YubiKey 5C — work’"
                value={label}
                onChange={(e) => setLabel(e.target.value)}
                required
              />
            </label>

            {registerMutation.error && (
              <section className="card error">
                <h3>Register failed</h3>
                <p>{(registerMutation.error as Error).message}</p>
              </section>
            )}

            <div className="form-actions">
              <button
                type="submit"
                className="primary"
                disabled={registerMutation.isPending || label.trim() === ""}
              >
                {registerMutation.isPending
                  ? "Verifying…"
                  : "Register"}
              </button>
            </div>
          </form>
        </section>
      )}

      {revokeMutation.error && (
        <section className="card error">
          <h3>Revoke failed</h3>
          <p>{(revokeMutation.error as Error).message}</p>
        </section>
      )}

      <section className="card">
        <table className="data-table">
          <thead>
            <tr>
              <th>Label</th>
              <th>Credential ID</th>
              <th>Registered</th>
              <th>Last used</th>
              <th></th>
            </tr>
          </thead>
          <tbody>
            {query.isPending && (
              <tr>
                <td colSpan={5}>Loading…</td>
              </tr>
            )}
            {passkeys.length === 0 && !query.isPending && (
              <tr>
                <td colSpan={5}>
                  <div className="empty-state">
                    <span className="empty-icon" aria-hidden="true">
                      <KeyRound />
                    </span>
                    <h4>No passkeys registered</h4>
                    <p>Claim the install URL or register a new passkey.</p>
                  </div>
                </td>
              </tr>
            )}
            {passkeys.map((p) => (
              <tr key={p.credentialId}>
                <td>{p.label}</td>
                <td>
                  <code className="truncate" title={p.credentialId}>
                    {p.credentialId}
                  </code>
                </td>
                <td>{formatDate(p.registeredAt)}</td>
                <td>
                  {p.lastUsedAt ? (
                    formatDate(p.lastUsedAt)
                  ) : (
                    <span className="muted">never</span>
                  )}
                </td>
                <td>
                  <button
                    type="button"
                    className="secondary destructive"
                    disabled={revokeMutation.isPending || onlyOne}
                    title={
                      onlyOne
                        ? "Cannot revoke your last passkey"
                        : undefined
                    }
                    onClick={async () => {
                      const ok = await confirm({
                        title: `Revoke "${p.label}"?`,
                        message:
                          "You'll need to verify with another passkey. The revoked passkey can no longer sign in.",
                        confirmLabel: "Revoke",
                        destructive: true,
                      });
                      if (ok) {
                        revokeMutation.mutate({
                          credentialId: p.credentialId,
                        });
                      }
                    }}
                  >
                    Revoke
                  </button>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
        {onlyOne && (
          <p className="muted">
            The Revoke button is disabled because you only have one
            passkey. Register a second one first.
          </p>
        )}
      </section>
    </section>
  );
}