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
86 changes: 86 additions & 0 deletions packages/extension/src/manager/key-name.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// What a key may be called, and what the console must say before it renames one.
//
// `keys/rename/0.1` says only that `newKeyId` is a non-empty string — the
// agent's gate is narrower than the specification's, and it is the one that
// answers. `vti_common::identifier::validate_identifier` accepts
// `[A-Za-z0-9._-]` up to 64 bytes and rejects everything else, because a key id
// lands as a store key and a caller who can inject `:` or `/` collides with an
// adjacent keyspace.
//
// So this module exists for the same reason `grant-command.ts` does: the rule
// is the agent's, but a console that does not apply it hands the operator a
// refusal instead of a form. It is a **pre-flight, not the decision** — the
// agent still answers, and its refusal is still rendered. Nothing here may
// widen the rule; a value this module accepts and the agent refuses is a
// message the operator can act on, while the reverse is a name the agent would
// have taken and the console pretended it would not.
//
// The shape that made this necessary: every key this agent mints for a DID is
// named by a DID URL (`did:webvh:…#key-0`), which is 86 bytes of `:` and `#` —
// not a name the rename gate can ever accept. The old editor pre-filled the
// field with exactly that, so the only thing pressing Save could produce was
// `new_key_id is 86 bytes; maximum is 64`.

/** The agent's cap, in bytes — `validate_identifier`'s `MAX_IDENTIFIER_LEN`. */
export const MAX_KEY_NAME_BYTES = 64;

const ALLOWED = /^[A-Za-z0-9._-]+$/;

/**
* Why the agent would refuse this name, or `null` if it would take it.
*
* Phrased for the operator rather than as the agent's own wording: the agent
* reports bytes and a character class, which is an accurate description of a
* gate and a poor description of what to type instead.
*/
export function keyNameProblem(name: string): string | null {
if (name === "") return "A name cannot be empty.";
// The common mistake is pasting the id that is already on screen. It fails
// the character rule *and* the length one, and neither message says the
// thing worth knowing: a name is not an address.
if (name.startsWith("did:")) {
return "A name is not a DID URL — your agent stores one as a plain label: letters, digits, and . - _ only.";
}
if (!ALLOWED.test(name)) {
return "Your agent accepts letters, digits, and . - _ only.";
}
const bytes = new TextEncoder().encode(name).length;
if (bytes > MAX_KEY_NAME_BYTES) {
return `That is ${bytes} characters; your agent accepts at most ${MAX_KEY_NAME_BYTES}.`;
}
return null;
}

/** Whether a key's current id is one the agent would accept as a new name —
* false for every DID-bound key, which is most of them. */
export function isNameable(keyId: string): boolean {
return keyNameProblem(keyId) === null;
}

/**
* A name to offer for a key that has none — derived from the DID URL it is
* addressed by today.
*
* `did:webvh:Qm…:webvh.storm.ws:vdr-host#key-0` becomes `vdr-host-key-0`: the
* last path segment, which is what a person calls that identity, and the
* fragment, which is what distinguishes its keys from each other. Offered as a
* *placeholder* rather than a value — a pre-filled suggestion is one press away
* from a rename nobody chose, and a rename detaches the key from the document
* that names it.
*
* Returns `""` when nothing usable survives, which the caller must treat as
* "no suggestion" rather than as a name.
*/
export function suggestKeyName(keyId: string): string {
const hash = keyId.indexOf("#");
const address = hash === -1 ? keyId : keyId.slice(0, hash);
const fragment = hash === -1 ? "" : keyId.slice(hash + 1);
const tail = address.split(":").filter(Boolean).pop() ?? "";
const joined = [tail, fragment].filter(Boolean).join("-");
const cleaned = joined
.replace(/[^A-Za-z0-9._-]+/g, "-")
.replace(/-{2,}/g, "-")
.replace(/^[-._]+|[-._]+$/g, "");
// Cleaned is ASCII, so bytes and characters agree and a slice is safe.
return cleaned.slice(0, MAX_KEY_NAME_BYTES).replace(/[-._]+$/, "");
}
128 changes: 110 additions & 18 deletions packages/extension/src/manager/panes/keys.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
// key is not an administration task, it is use, and a console that offers a
// "sign this" box turns an audit trail of key management into an oracle.

import { useCallback, useState } from "react";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import {
keysCreate,
keysList,
Expand All @@ -29,6 +29,12 @@ import { Loading, LoadError, Table, Truncated, type Column } from "../table.js";
import { useAsync } from "../use-async.js";
import { formatDate } from "../format.js";
import { hasRole, type Authority, type Parties } from "../use-vta.js";
import {
isNameable,
keyNameProblem,
suggestKeyName,
MAX_KEY_NAME_BYTES,
} from "../key-name.js";
import type { ContextSelection } from "../context-column.js";

const PAGE = 50;
Expand Down Expand Up @@ -198,37 +204,101 @@ function CreateKey({
);
}

function RenameKey({
/**
* Rename a key — the editor, which opens beneath the row rather than inside the
* actions column.
*
* Two things this is shaped by, both of which the previous version got wrong.
*
* **A name is not the id.** Every key an agent mints for a DID is addressed by
* a DID URL, and `keys/rename` will not take one back: the agent's gate is
* `[A-Za-z0-9._-]` up to 64 bytes (`key-name.ts`), so the pre-filled current id
* was the one value guaranteed to be refused — an operator pressing Save got
* `new_key_id is 86 bytes; maximum is 64` and no way to read what would work.
* The field now starts empty for such a key, with a suggestion as placeholder,
* and the rule is stated before it is broken.
*
* **And it is a one-way door.** A DID document addresses this key by the id it
* has today; the agent cannot be given that id again, because it contains `:`
* and `#`. So the warning is not decoration — nothing undoes it.
*/
function RenameEditor({
parties,
record,
onClose,
onDone,
}: {
parties: Parties;
record: KeyRecord;
onClose: () => void;
onDone: () => void;
}) {
const [open, setOpen] = useState(false);
const [next, setNext] = useState(record.keyId);
const nameable = isNameable(record.keyId);
const [next, setNext] = useState(nameable ? record.keyId : "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState<ConsentRequiredError | null>(null);
const fieldId = useId();
const input = useRef<HTMLInputElement | null>(null);

if (!open) {
return (
<Button kind="quiet" onClick={() => setOpen(true)}>
Rename
</Button>
);
}
// Opened from a button several columns to the right; without this the caret
// is nowhere and the row that just appeared looks like a decoration.
useEffect(() => {
input.current?.focus();
}, []);

const typed = next.trim();
const suggestion = nameable ? "" : suggestKeyName(record.keyId);
// Nothing typed is not a problem to report — it is the state the field opens
// in. Reporting it would put a red line under an empty box.
const problem = typed === "" ? null : keyNameProblem(typed);
const unchanged = typed === record.keyId;

return (
<div style={{ display: "grid", gap: 7 }}>
<input style={fieldStyle} value={next} onChange={(e) => setNext(e.target.value)} />
<div style={{ display: "grid", gap: 8, maxWidth: 620, paddingTop: 2 }}>
<label htmlFor={fieldId} style={{ fontSize: t.xs, color: c.muted }}>
NEW NAME
</label>
<input
id={fieldId}
ref={input}
style={{ ...fieldStyle, width: "100%", fontFamily: font.mono }}
value={next}
{...(suggestion ? { placeholder: suggestion } : {})}
onChange={(e) => setNext(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") onClose();
}}
/>
<span style={{ fontSize: t.xs, color: c.faint, lineHeight: 1.5 }}>
Letters, digits, and <code style={{ fontFamily: font.mono }}>. - _</code> — up to{" "}
{MAX_KEY_NAME_BYTES} characters. Your agent refuses anything else.
{suggestion && (
<>
{" "}
The field is empty on purpose —{" "}
<code style={{ fontFamily: font.mono }}>{suggestion}</code> is a suggestion, not a
value.
</>
)}
</span>

{!nameable && (
<Note tone="warn">
This key is addressed as <code style={{ fontFamily: font.mono }}>{record.keyId}</code>,
which is how a DID document names it. A name cannot contain{" "}
<code style={{ fontFamily: font.mono }}>:</code> or{" "}
<code style={{ fontFamily: font.mono }}>#</code>, so renaming it detaches the key from
that document and your agent will not accept the old id back.
</Note>
)}
{problem && <Note tone="danger">{problem}</Note>}
{error && <Note tone="danger">{error}</Note>}
{pending && <ConsentCeremony pending={pending} />}

<div style={{ display: "flex", gap: 6 }}>
<Button
disabled={busy || !next.trim() || next === record.keyId}
disabled={busy || typed === "" || Boolean(problem) || unchanged}
onClick={() => {
setBusy(true);
setError(null);
Expand All @@ -237,22 +307,22 @@ function RenameKey({
await keysRename(managerSender, {
...parties,
keyId: record.keyId,
newKeyId: next.trim(),
newKeyId: typed,
});
},
{ onConsent: setPending, onError: setError },
).then((ok) => {
setBusy(false);
if (ok) {
setOpen(false);
onClose();
onDone();
}
});
}}
>
{busy ? "Saving…" : "Save"}
</Button>
<Button kind="quiet" onClick={() => setOpen(false)}>
<Button kind="quiet" onClick={onClose}>
Cancel
</Button>
</div>
Expand All @@ -274,6 +344,10 @@ export function KeysPane({
contextHeading?: string | undefined;
}) {
const [limit, setLimit] = useState(PAGE);
// Which key is being renamed, held here rather than in the row: the editor
// renders as the table's expanded row, which is a sibling of the cells, not
// a child of one.
const [renaming, setRenaming] = useState<string | null>(null);

const list = useAsync(
() =>
Expand Down Expand Up @@ -329,7 +403,12 @@ export function KeysPane({
<span style={{ color: c.faint, fontSize: t.xs }}>revoked</span>
) : (
<div style={{ display: "grid", gap: 8, minWidth: 190 }}>
<RenameKey parties={parties} record={k} onDone={list.reload} />
<Button
kind="quiet"
onClick={() => setRenaming((id) => (id === k.keyId ? null : k.keyId))}
>
Rename
</Button>
<Destructive<KeyRecord>
label="Revoke"
disabledReason={revokeDenied}
Expand Down Expand Up @@ -372,6 +451,19 @@ export function KeysPane({
columns={columns}
rows={list.data.keys}
rowKey={(k) => k.keyId}
// Beneath the key it renames, full width — the actions column is
// the narrowest in the table and a DID-shaped field squeezed into
// it shows about eight characters of the thing being edited.
expanded={(k) =>
renaming === k.keyId ? (
<RenameEditor
parties={parties}
record={k}
onClose={() => setRenaming(null)}
onDone={list.reload}
/>
) : null
}
empty={
contextId
? `No keys in ${contextId}. Keys minted into this context appear here.`
Expand Down
12 changes: 12 additions & 0 deletions packages/extension/src/manager/panes/rooms-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,18 @@
// records by that prefix — a convention of this agent rather than a contract,
// which is why typing one stays possible.
//
// **The key record's id and the DID document's verification method are not the
// same string for a templated mint, and here they disagree by one.** The `room`
// and `room-host` built-in templates (`vta-sdk/templates/`) number their
// verification methods `#key-1` (signing) and `#key-2` (key agreement), while
// the create path saves the key records as `#key-0` and `#key-1` — so the
// document's `#key-1` names the signing key and the keystore's names the
// x25519 one. The public keys still line up positionally, which is why a room
// credential (the VTA signs it naming `{room_did}#key-1`, matching the
// template) verifies; what does not survive is *addressing a key by the id the
// document publishes*. Hence the filter below: the offer is by prefix and
// `keyType !== "x25519"`, never by fragment number. Reported upstream.
//
// Minting can succeed and registration fail. The DID is real and the key
// identifier is the half nobody writes down, so `Minted` keeps both on screen
// and the flow switches to the existing-identity path pre-filled: the retry
Expand Down
78 changes: 78 additions & 0 deletions packages/extension/tests/key-name.test.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// What the console lets an operator type into the rename field.
//
// The rule is the agent's (`vti_common::identifier`), applied here so a refusal
// arrives as a sentence under the field rather than as
// `new_key_id is 86 bytes; maximum is 64` after the fact. Every assertion is a
// shape the live console produced: a DID-URL key id pre-filled into the field,
// and the suggestion offered in its place.

import { strict as assert } from "node:assert";
import { test } from "node:test";

import {
isNameable,
keyNameProblem,
suggestKeyName,
MAX_KEY_NAME_BYTES,
} from "../src/manager/key-name.js";

const DID_KEY = "did:webvh:QmRqxWCVUE6Y474EPdNZmR9m3giTRyDgmXSdidvEy7iSwq:webvh.storm.ws:vdr-host#key-1";

test("an ordinary slug is accepted", () => {
for (const ok of ["app-signing-key-2026", "vdr.host", "_private", "CamelCase", "0"]) {
assert.equal(keyNameProblem(ok), null, ok);
assert.equal(isNameable(ok), true, ok);
}
});

test("the id the field used to be pre-filled with is refused, and says why", () => {
// The bug this module ends: the only thing pressing Save could do was fail.
assert.equal(isNameable(DID_KEY), false);
const why = keyNameProblem(DID_KEY);
assert.ok(why?.includes("not a DID URL"), why ?? "no reason given");
});

test("a separator is refused whatever its length", () => {
// These are the agent's own attack shapes — a key id lands as a store key.
for (const bad of ["global:evil", "../../etc", "my/ctx", "with space", "quote\"it"]) {
assert.notEqual(keyNameProblem(bad), null, bad);
}
});

test("the length cap is the agent's, and counts bytes", () => {
assert.equal(keyNameProblem("a".repeat(MAX_KEY_NAME_BYTES)), null);
const over = keyNameProblem("a".repeat(MAX_KEY_NAME_BYTES + 1));
assert.ok(over?.includes(String(MAX_KEY_NAME_BYTES)), over ?? "no reason given");
});

test("empty is a state, not a violation of the character rule", () => {
assert.ok(keyNameProblem("")?.includes("empty"));
});

test("the suggestion names the identity and the key, and would be accepted", () => {
const s = suggestKeyName(DID_KEY);
assert.equal(s, "vdr-host-key-1");
assert.equal(keyNameProblem(s), null);
});

test("a suggestion is derived from the last path segment, not the SCID", () => {
const s = suggestKeyName(
"did:webvh:Qma9EKoeEJqtWpvAdGdrsZMcEFL5QpKrVYsJDyVxd87pCd:webvh.storm.ws:rooms:open-demo#key-0",
);
assert.equal(s, "open-demo-key-0");
});

test("every suggestion is a name the agent would take", () => {
// The placeholder is read as an offer. One the agent refuses is worse than
// none — it teaches the rule wrongly at exactly the moment it is being read.
for (const id of [
DID_KEY,
"did:key:z6MkExample#z6MkExample",
"did:webvh:Qm:host#key-0",
"did:peer:2.Ez6LS.Vz6Mk#key-1",
]) {
const s = suggestKeyName(id);
assert.notEqual(s, "", id);
assert.equal(keyNameProblem(s), null, `${id} → ${s}`);
}
});
Loading