From 7c203b0ea98678e8262ae2a3a5b611207eee6290 Mon Sep 17 00:00:00 2001 From: Leonardo Custodio Date: Wed, 27 May 2026 19:59:12 -0300 Subject: [PATCH 1/2] Add new chapter to XID tutorial --- .../xid-tutorial/StepBackupInception.vue | 137 ++++++++++++ .../xid-tutorial/StepBackupSshKey.vue | 171 ++++++++++++++ .../components/xid-tutorial/StepEditions.vue | 6 +- .../xid-tutorial/StepKeyCompromise.vue | 211 ++++++++++++++++++ .../xid-tutorial/StepOperationalKeys.vue | 187 ++++++++++++++++ .../xid-tutorial/StepUpdatingKeys.vue | 178 +++++++++++++++ .../app/composables/useXidTutorial.ts | 156 ++++++++++++- apps/playground/app/layouts/default.vue | 3 +- apps/playground/app/pages/xid.vue | 5 + .../app/utils/xid-tutorial/backup.ts | 73 ++++++ .../playground/app/utils/xid-tutorial/edge.ts | 17 +- .../playground/app/utils/xid-tutorial/keys.ts | 113 ++++++++++ .../app/utils/xid-tutorial/sections.ts | 46 ++++ 13 files changed, 1285 insertions(+), 18 deletions(-) create mode 100644 apps/playground/app/components/xid-tutorial/StepBackupInception.vue create mode 100644 apps/playground/app/components/xid-tutorial/StepBackupSshKey.vue create mode 100644 apps/playground/app/components/xid-tutorial/StepKeyCompromise.vue create mode 100644 apps/playground/app/components/xid-tutorial/StepOperationalKeys.vue create mode 100644 apps/playground/app/components/xid-tutorial/StepUpdatingKeys.vue create mode 100644 apps/playground/app/utils/xid-tutorial/backup.ts create mode 100644 apps/playground/app/utils/xid-tutorial/keys.ts diff --git a/apps/playground/app/components/xid-tutorial/StepBackupInception.vue b/apps/playground/app/components/xid-tutorial/StepBackupInception.vue new file mode 100644 index 00000000..7cf75b9a --- /dev/null +++ b/apps/playground/app/components/xid-tutorial/StepBackupInception.vue @@ -0,0 +1,137 @@ + + + diff --git a/apps/playground/app/components/xid-tutorial/StepBackupSshKey.vue b/apps/playground/app/components/xid-tutorial/StepBackupSshKey.vue new file mode 100644 index 00000000..a4974196 --- /dev/null +++ b/apps/playground/app/components/xid-tutorial/StepBackupSshKey.vue @@ -0,0 +1,171 @@ + + + diff --git a/apps/playground/app/components/xid-tutorial/StepEditions.vue b/apps/playground/app/components/xid-tutorial/StepEditions.vue index e6f63ee9..35773b56 100644 --- a/apps/playground/app/components/xid-tutorial/StepEditions.vue +++ b/apps/playground/app/components/xid-tutorial/StepEditions.vue @@ -164,9 +164,9 @@ function useCurrent(slot: 'paste1' | 'paste2') {
diff --git a/apps/playground/app/components/xid-tutorial/StepKeyCompromise.vue b/apps/playground/app/components/xid-tutorial/StepKeyCompromise.vue new file mode 100644 index 00000000..2d788fa3 --- /dev/null +++ b/apps/playground/app/components/xid-tutorial/StepKeyCompromise.vue @@ -0,0 +1,211 @@ + + + diff --git a/apps/playground/app/components/xid-tutorial/StepOperationalKeys.vue b/apps/playground/app/components/xid-tutorial/StepOperationalKeys.vue new file mode 100644 index 00000000..1f1845b7 --- /dev/null +++ b/apps/playground/app/components/xid-tutorial/StepOperationalKeys.vue @@ -0,0 +1,187 @@ + + + diff --git a/apps/playground/app/components/xid-tutorial/StepUpdatingKeys.vue b/apps/playground/app/components/xid-tutorial/StepUpdatingKeys.vue new file mode 100644 index 00000000..e00d7075 --- /dev/null +++ b/apps/playground/app/components/xid-tutorial/StepUpdatingKeys.vue @@ -0,0 +1,178 @@ + + + diff --git a/apps/playground/app/composables/useXidTutorial.ts b/apps/playground/app/composables/useXidTutorial.ts index c687ee47..d1c49803 100644 --- a/apps/playground/app/composables/useXidTutorial.ts +++ b/apps/playground/app/composables/useXidTutorial.ts @@ -1,5 +1,6 @@ import { XIDPrivateKeyOptions, XIDGeneratorOptions, Service, Privilege, type Key } from "@bcts/xid"; import type { Envelope } from "@bcts/envelope"; +import type { PublicKeys } from "@bcts/components"; import type { IdentityName, IdentitySlot } from "@/utils/xid-tutorial/types"; import { TUTORIAL_SECTIONS } from "@/utils/xid-tutorial/sections"; import { @@ -14,6 +15,13 @@ import { sshPublicKeyText, type TutorialScheme, } from "@/utils/xid-tutorial/identity"; +import { + addOperationalKey, + setKeyPermissions, + rotateKey, + removeKeyByNickname, + keyInventory as buildKeyInventory, +} from "@/utils/xid-tutorial/keys"; type AttachmentEnvelope = Envelope & { attachmentVendor(): string; @@ -21,7 +29,8 @@ type AttachmentEnvelope = Envelope & { attachmentPayload(): Envelope; }; -const STORAGE_KEY = "xid-tutorial-state-v2"; +const STORAGE_KEY = "xid-tutorial-state-v3"; +const LEGACY_STORAGE_KEY_V2 = "xid-tutorial-state-v2"; const LEGACY_STORAGE_KEY = "xid-tutorial-state"; /** Each identity is a full workspace slot — active identity is the one step components operate on. */ @@ -155,6 +164,11 @@ export function useXidTutorial() { void docVersion.value; return activeDoc.value?.provenance() ?? null; }); + const keyInventory = computed(() => { + void docVersion.value; + const doc = activeDoc.value; + return doc ? buildKeyInventory(doc) : []; + }); const currentSectionMeta = computed(() => TUTORIAL_SECTIONS[currentSection.value] ?? null); const progress = computed(() => { const done = sectionsCompleted.value.filter(Boolean).length; @@ -342,6 +356,106 @@ export function useXidTutorial() { } } + // ---- Operational keys, rotation & revocation (Chapter 5) ---- + /** §5.1 — add an operational key (laptop/portable) with scoped permissions. */ + function addOperationalKeyToActive(nickname: string, scheme: TutorialScheme, allow: Privilege[]) { + const doc = activeDoc.value; + if (!doc) return null; + try { + error.value = null; + const side = addOperationalKey(doc, nickname, scheme, allow); + invalidatePrivateEnvelope(activeIdentity.value); + bumpDoc(); + saveState(); + return side; + } catch (e) { + error.value = e instanceof Error ? e.message : "Failed to add operational key"; + return null; + } + } + + /** §5.2 — replace a key's allowed-permission set (e.g. drop `access`). */ + function updateActiveKeyPermissions(pubKeys: PublicKeys, allow: Privilege[]): boolean { + const doc = activeDoc.value; + if (!doc) return false; + try { + error.value = null; + const ok = setKeyPermissions(doc, pubKeys, allow); + invalidatePrivateEnvelope(activeIdentity.value); + bumpDoc(); + saveState(); + return ok; + } catch (e) { + error.value = e instanceof Error ? e.message : "Failed to update key permissions"; + return false; + } + } + + /** §5.2 — rotate a key: add a new one, remove the old. */ + function rotateActiveKey( + oldPubKeys: PublicKeys, + nickname: string, + scheme: TutorialScheme, + allow: Privilege[], + ) { + const doc = activeDoc.value; + if (!doc) return null; + try { + error.value = null; + const side = rotateKey(doc, oldPubKeys, nickname, scheme, allow); + invalidatePrivateEnvelope(activeIdentity.value); + bumpDoc(); + saveState(); + return side; + } catch (e) { + error.value = e instanceof Error ? e.message : "Failed to rotate key"; + return null; + } + } + + /** §5.5 — revoke (remove) a key by nickname; returns its public keys. */ + function removeActiveKeyByNickname(name: string): PublicKeys | undefined { + const doc = activeDoc.value; + if (!doc) return undefined; + try { + error.value = null; + const removed = removeKeyByNickname(doc, name); + invalidatePrivateEnvelope(activeIdentity.value); + bumpDoc(); + saveState(); + return removed; + } catch (e) { + error.value = e instanceof Error ? e.message : "Failed to remove key"; + return undefined; + } + } + + /** + * §5.1 / §5.5 — derive an "operational XID": an elided-private copy of the + * master with the inception (master) key — and optionally other named keys — + * removed. Built from a reloaded copy so the active slot keeps its full + * master (§5.3/§5.5 still need the inception key). Returns the envelope; does + * NOT mutate the active document. + */ + function buildOperationalEnvelope(alsoRemoveNicknames: string[] = []): Envelope | null { + const slot = activeSlot.value; + if (!slot.document) return null; + try { + error.value = null; + const persist = getPersistEnvelope(); + if (!persist) return null; + const copy = loadXidFromUr(persist.urString(), slot.password || undefined); + copy.removeInceptionKey(); + for (const name of alsoRemoveNicknames) removeKeyByNickname(copy, name); + return copy.toEnvelope(XIDPrivateKeyOptions.Elide, XIDGeneratorOptions.Elide, { + type: "none", + }); + } catch (e) { + error.value = e instanceof Error ? e.message : "Failed to build operational XID"; + return null; + } + } + // ---- Resolution ---- function addResolutionMethod(uri: string) { const doc = activeDoc.value; @@ -528,6 +642,7 @@ export function useXidTutorial() { saveTimer = null; } localStorage.removeItem(STORAGE_KEY); + localStorage.removeItem(LEGACY_STORAGE_KEY_V2); localStorage.removeItem(LEGACY_STORAGE_KEY); } bumpDoc(); @@ -536,7 +651,7 @@ export function useXidTutorial() { // ---- Persistence ---- type PersistedSlot = { ur?: string; password?: string }; type Persisted = { - version: 2; + version: 2 | 3; currentSection: number; sectionsCompleted: boolean[]; activeIdentity: IdentityName; @@ -544,6 +659,17 @@ export function useXidTutorial() { artifacts?: Record; }; + /** Pad/truncate a stored completion array to the current section count. + * Chapter 5 grew the registry from 14 → 19 sections; a v2 array of length + * 14 must be widened so indices 14–18 default to `false`. */ + function normalizeCompleted(arr?: boolean[]): boolean[] { + const out = new Array(TUTORIAL_SECTIONS.length).fill(false); + if (Array.isArray(arr)) { + for (let i = 0; i < Math.min(arr.length, out.length); i++) out[i] = !!arr[i]; + } + return out; + } + function saveState() { if (!import.meta.client) return; if (saveTimer) clearTimeout(saveTimer); @@ -556,7 +682,7 @@ export function useXidTutorial() { function persistNow() { try { const persisted: Persisted = { - version: 2, + version: 3, currentSection: currentSection.value, sectionsCompleted: sectionsCompleted.value, activeIdentity: activeIdentity.value, @@ -582,13 +708,17 @@ export function useXidTutorial() { function restoreState() { if (!import.meta.client) return; try { - const raw = localStorage.getItem(STORAGE_KEY); + // v3 (current) and v2 share an identical on-disk shape — Chapter 5 only + // appended sections. Read either; a v2 payload is migrated forward (its + // shorter `sectionsCompleted` is padded) and re-persisted under v3. + const rawV3 = localStorage.getItem(STORAGE_KEY); + const rawV2 = rawV3 ? null : localStorage.getItem(LEGACY_STORAGE_KEY_V2); + const raw = rawV3 ?? rawV2; if (raw) { const p = JSON.parse(raw) as Persisted; - if (p.version !== 2) return; + if (p.version !== 2 && p.version !== 3) return; currentSection.value = p.currentSection ?? 0; - sectionsCompleted.value = - p.sectionsCompleted ?? new Array(TUTORIAL_SECTIONS.length).fill(false); + sectionsCompleted.value = normalizeCompleted(p.sectionsCompleted); activeIdentity.value = p.activeIdentity ?? "amira"; artifacts.value = p.artifacts ?? {}; for (const [name, data] of Object.entries(p.identities)) { @@ -602,6 +732,11 @@ export function useXidTutorial() { } } bumpDoc(); + if (rawV2) { + // Migrate forward: write under v3 and drop the v2 key. + persistNow(); + localStorage.removeItem(LEGACY_STORAGE_KEY_V2); + } return; } // Legacy v1 migration @@ -666,6 +801,7 @@ export function useXidTutorial() { serviceList, edgeList, provenanceMark, + keyInventory, // identity lifecycle setActive, createIdentity, @@ -674,6 +810,12 @@ export function useXidTutorial() { // keys addSideKey, generateSshKey, + // operational keys / rotation / revocation (Chapter 5) + addOperationalKeyToActive, + updateActiveKeyPermissions, + rotateActiveKey, + removeActiveKeyByNickname, + buildOperationalEnvelope, // resolution addResolutionMethod, removeResolutionMethodUri, diff --git a/apps/playground/app/layouts/default.vue b/apps/playground/app/layouts/default.vue index 2d6339cf..e8377826 100644 --- a/apps/playground/app/layouts/default.vue +++ b/apps/playground/app/layouts/default.vue @@ -51,8 +51,7 @@ const navigationItems: NavigationMenuItem[] = [ { label: 'XID Tutorial', icon: 'i-heroicons-academic-cap', - to: '/xid', - badge: 'WIP' + to: '/xid' }, { type: 'label', diff --git a/apps/playground/app/pages/xid.vue b/apps/playground/app/pages/xid.vue index 1f144d72..9c01cb7a 100644 --- a/apps/playground/app/pages/xid.vue +++ b/apps/playground/app/pages/xid.vue @@ -373,6 +373,11 @@ function globalIndex(sectionId: string): number { + + + + + diff --git a/apps/playground/app/utils/xid-tutorial/backup.ts b/apps/playground/app/utils/xid-tutorial/backup.ts new file mode 100644 index 00000000..cb32a090 --- /dev/null +++ b/apps/playground/app/utils/xid-tutorial/backup.ts @@ -0,0 +1,73 @@ +import { Envelope } from "@bcts/envelope"; +import { SymmetricKey, SSKRGroupSpec, SSKRSpec, KeyDerivationMethod } from "@bcts/components"; + +const textEncoder = new TextEncoder(); + +// `sskrJoin` is a *static* extension method and `fromURString` a static +// constructor; neither carries its type across the package boundary, so we +// reach them through a narrow cast (the same pattern the CLI `join` command +// and `identity.ts` use). Instance methods (`wrap`, `encryptSubject`, +// `sskrSplitFlattened`, `unwrap`, `addSecret`) are typed on `Envelope`. +const EnvelopeStatic = Envelope as unknown as { + sskrJoin(envelopes: Envelope[]): Envelope; + fromURString(s: string): Envelope; +}; + +/** + * SSKR-split an envelope into flat share UR strings. Byte-for-byte mirror of + * `envelope sskr split` (`tools/envelope-cli/src/cmd/sskr/split.ts`): wrap the + * envelope, encrypt its subject under a fresh random content key, then split + * that key across the requested groups. Default tutorial spec is `(1, [[2,3]])` + * → a single 2-of-3 group. (§5.3 / §5.4 / §5.5) + */ +export function sskrSplitEnvelope( + env: Envelope, + groupThreshold: number, + groups: [number, number][], +): string[] { + const contentKey = SymmetricKey.new(); + const wrapped = env.wrap(); + const encrypted = wrapped.encryptSubject(contentKey); + const groupSpecs = groups.map(([m, n]) => SSKRGroupSpec.new(m, n)); + const spec = SSKRSpec.new(groupThreshold, groupSpecs); + const shares = encrypted.sskrSplitFlattened(spec, contentKey); + return shares.map((s) => s.urString()); +} + +/** + * Recombine SSKR shares back into the original envelope. Mirror of + * `envelope sskr join`: combine the shares to recover the wrapped envelope, + * then unwrap it. (§5.3 / §5.5) + */ +export function sskrJoinShares(shareUrs: string[]): Envelope { + const envs = shareUrs.map((ur) => EnvelopeStatic.fromURString(ur.trim())); + const wrapped = EnvelopeStatic.sskrJoin(envs); + return wrapped.unwrap(); +} + +/** Safe wrapper around {@link sskrJoinShares}. */ +export function trySskrJoin( + shareUrs: string[], +): { ok: true; env: Envelope } | { ok: false; reason: string } { + try { + return { ok: true, env: sskrJoinShares(shareUrs) }; + } catch (e) { + return { ok: false, reason: e instanceof Error ? e.message : "recovery failed" }; + } +} + +/** + * Password-encrypt an envelope's subject (Argon2id). Mirror of + * `envelope encrypt --password` (`tools/envelope-cli/src/cmd/encrypt.ts`): + * encrypt the subject under a fresh content key, then lock that content key + * with the password via `addSecret`. (§5.4) + */ +export function passwordEncrypt(env: Envelope, password: string): Envelope { + const contentKey = SymmetricKey.new(); + const encrypted = env.encryptSubject(contentKey); + return encrypted.addSecret( + KeyDerivationMethod.Argon2id, + textEncoder.encode(password), + contentKey, + ); +} diff --git a/apps/playground/app/utils/xid-tutorial/edge.ts b/apps/playground/app/utils/xid-tutorial/edge.ts index 6ba09636..60de9036 100644 --- a/apps/playground/app/utils/xid-tutorial/edge.ts +++ b/apps/playground/app/utils/xid-tutorial/edge.ts @@ -50,17 +50,22 @@ function buildSubEnvelope(input: EdgeTargetInput): Envelope { */ export function buildSignedEdge(input: EdgeBuildInput, signer: PrivateKeys): Envelope { const src = buildSubEnvelope(input.source); - const tgt = buildSubEnvelope(input.target); + let tgt = buildSubEnvelope(input.target); - let edge = Envelope.new(input.subject) + // BCR-2026-003 (synced in @bcts/envelope v0.43.0): an edge subject may carry + // *only* `isA`, `source`, and `target`. Any additional claim detail + // (`conformsTo`, `date`, `verifiableAt`) belongs on the target sub-envelope — + // exactly as upstream XID-Quickstart §3.1 builds it. Placing them here keeps + // `Envelope.validateEdge()` happy and matches the Rust reference structure. + if (input.conformsTo) tgt = tgt.addAssertion(CONFORMS_TO, input.conformsTo); + if (input.date) tgt = tgt.addAssertion(DATE, input.date.toISOString()); + if (input.verifiableAt) tgt = tgt.addAssertion(VERIFIABLE_AT, input.verifiableAt); + + const edge = Envelope.new(input.subject) .addAssertion(IS_A, input.isA) .addAssertionEnvelope(Envelope.newAssertion(SOURCE, src)) .addAssertionEnvelope(Envelope.newAssertion(TARGET, tgt)); - if (input.conformsTo) edge = edge.addAssertion(CONFORMS_TO, input.conformsTo); - if (input.date) edge = edge.addAssertion(DATE, input.date.toISOString()); - if (input.verifiableAt) edge = edge.addAssertion(VERIFIABLE_AT, input.verifiableAt); - const options: SigningOptions | undefined = signer.signingPrivateKey().isSsh() ? { type: "Ssh", namespace: "envelope", hashAlg: "sha256" } : undefined; diff --git a/apps/playground/app/utils/xid-tutorial/keys.ts b/apps/playground/app/utils/xid-tutorial/keys.ts new file mode 100644 index 00000000..8fcc0a7c --- /dev/null +++ b/apps/playground/app/utils/xid-tutorial/keys.ts @@ -0,0 +1,113 @@ +import { Key, type Privilege, type XIDDocument } from "@bcts/xid"; +import type { PublicKeys } from "@bcts/components"; +import { generateSideKey, type TutorialScheme } from "./identity"; +import type { SideKey } from "./types"; + +/** Read-model for the key-inventory panel (§5.1). */ +export interface KeyInventoryEntry { + nickname: string; + scheme: string; + permissions: Privilege[]; + isInception: boolean; + pubKeysUr: string; + referenceHex: string; +} + +/** + * Add an operational key (laptop / portable / …) with a nickname and a scoped + * permission set. Mirrors the upstream §5.1 `envelope xid key add --nickname … + * --allow …` flow — generate a fresh keypair, wrap it in a `Key`, grant the + * requested privileges, and register it on the document. + */ +export function addOperationalKey( + doc: XIDDocument, + nickname: string, + scheme: TutorialScheme, + allow: Privilege[], +): SideKey { + const side = generateSideKey(nickname, scheme); + const key = Key.newWithPrivateKeys(side.prvKeys, side.pubKeys); + for (const p of allow) key.addPermission(p); + key.setNickname(nickname); + doc.addKey(key); + return side; +} + +/** Find a key on the document by its nickname (§5.2 / §5.5 `key find name`). */ +export function findKeyByNickname(doc: XIDDocument, name: string): Key | undefined { + return doc.keys().find((k) => k.nickname() === name); +} + +/** + * Replace a key's allowed-permission set. Uses the Rust-style take → mutate → + * re-add pattern so the change is unambiguously persisted on the document + * (§5.2 `key update`). + */ +export function setKeyPermissions( + doc: XIDDocument, + pubKeys: PublicKeys, + allow: Privilege[], +): boolean { + const key = doc.takeKey(pubKeys); + if (!key) return false; + const perms = key.permissionsMut(); + perms.allow.clear(); + for (const p of allow) perms.allow.add(p); + doc.addKey(key); + return true; +} + +/** + * Rotate a key: add a fresh key with a new nickname, then remove the old one + * (§5.2 Steps 4–6). Returns the new side key so the caller can surface its UR. + */ +export function rotateKey( + doc: XIDDocument, + oldPubKeys: PublicKeys, + newNickname: string, + scheme: TutorialScheme, + allow: Privilege[], +): SideKey { + const side = addOperationalKey(doc, newNickname, scheme, allow); + doc.removeKey(oldPubKeys); + return side; +} + +/** Remove a key by nickname; returns its public keys if it existed (§5.5). */ +export function removeKeyByNickname(doc: XIDDocument, name: string): PublicKeys | undefined { + const key = findKeyByNickname(doc, name); + if (!key) return undefined; + const pub = key.publicKeys(); + doc.removeKey(pub); + return pub; +} + +/** Build a read-model of every key on the document for the inventory panel. */ +export function keyInventory(doc: XIDDocument): KeyInventoryEntry[] { + return doc.keys().map((k) => { + const pub = k.publicKeys(); + let scheme = "Unknown"; + let isInception = false; + try { + const signing = pub.signingPublicKey(); + scheme = signing.keyType(); + isInception = doc.isInceptionSigningKey(signing); + } catch { + /* non-signing or malformed key */ + } + let pubKeysUr = ""; + try { + pubKeysUr = pub.urString(); + } catch { + /* */ + } + return { + nickname: k.nickname() || "(unnamed)", + scheme, + permissions: [...k.permissions().allow], + isInception, + pubKeysUr, + referenceHex: k.reference().toHex(), + }; + }); +} diff --git a/apps/playground/app/utils/xid-tutorial/sections.ts b/apps/playground/app/utils/xid-tutorial/sections.ts index 15dbbd79..c4996557 100644 --- a/apps/playground/app/utils/xid-tutorial/sections.ts +++ b/apps/playground/app/utils/xid-tutorial/sections.ts @@ -130,6 +130,51 @@ export const TUTORIAL_SECTIONS: SectionMeta[] = [ component: "StepEditions", needsIdentity: true, }, + { + id: "5.1", + chapter: 5, + section: 1, + title: "Operational Keys", + subtitle: "Scoped keys + operational XID", + component: "StepOperationalKeys", + needsIdentity: true, + }, + { + id: "5.2", + chapter: 5, + section: 2, + title: "Updating Keys", + subtitle: "Permissions & rotation", + component: "StepUpdatingKeys", + needsIdentity: true, + }, + { + id: "5.3", + chapter: 5, + section: 3, + title: "Backup Inception", + subtitle: "SSKR 2-of-3 split & recover", + component: "StepBackupInception", + needsIdentity: true, + }, + { + id: "5.4", + chapter: 5, + section: 4, + title: "Backup SSH Key", + subtitle: "Wrap, encrypt, shard", + component: "StepBackupSshKey", + needsIdentity: true, + }, + { + id: "5.5", + chapter: 5, + section: 5, + title: "Key Compromise", + subtitle: "Recover, revoke, re-key", + component: "StepKeyCompromise", + needsIdentity: true, + }, ]; export const CHAPTERS = [ @@ -137,6 +182,7 @@ export const CHAPTERS = [ { number: 2, title: "Making Claims" }, { number: 3, title: "Attesting with Edges" }, { number: 4, title: "Managing Your XIDs" }, + { number: 5, title: "Managing Your Keys" }, ]; export function sectionIndex(id: string): number { From cbe689911d8ca15a1d675211c404357585b031e4 Mon Sep 17 00:00:00 2001 From: Leonardo Custodio Date: Wed, 27 May 2026 20:03:15 -0300 Subject: [PATCH 2/2] Add disavowal --- .../xid-tutorial/StepKeyCompromise.vue | 69 ++++++++++++++++++- .../app/utils/xid-tutorial/disavowal.ts | 52 ++++++++++++++ .../playground/app/utils/xid-tutorial/keys.ts | 27 +++++++- 3 files changed, 144 insertions(+), 4 deletions(-) create mode 100644 apps/playground/app/utils/xid-tutorial/disavowal.ts diff --git a/apps/playground/app/components/xid-tutorial/StepKeyCompromise.vue b/apps/playground/app/components/xid-tutorial/StepKeyCompromise.vue index 2d788fa3..850a0f15 100644 --- a/apps/playground/app/components/xid-tutorial/StepKeyCompromise.vue +++ b/apps/playground/app/components/xid-tutorial/StepKeyCompromise.vue @@ -3,6 +3,9 @@ import { Privilege } from '@bcts/xid' import type { Envelope } from '@bcts/envelope' import { trySskrJoin } from '@/utils/xid-tutorial/backup' import { loadXidFromUr } from '@/utils/xid-tutorial/identity' +import { keyDisavowalInfo, type DisavowedKey } from '@/utils/xid-tutorial/keys' +import { buildSignedDisavowal } from '@/utils/xid-tutorial/disavowal' +import type { SideKey } from '@/utils/xid-tutorial/types' const { activeDoc, activeSlot, activeIdentity, setActive, keyInventory, getArtifact, @@ -20,11 +23,21 @@ const revokeNickname = ref('') const revokeDone = ref(false) const rekeyDone = ref(false) const provenanceAdvanced = ref(false) +// Captured at revoke time so Step D can name the keys in a signed disavowal, +// and the replacement key (the signer of that disavowal). +// shallowRef so Vue's UnwrapRef doesn't mangle the PublicKeys/Digest class types. +const disavowedKeys = shallowRef([]) +const replacementKey = shallowRef(null) // Step C — rebuild operational view const operationalTree = ref('') const operationalBuilt = ref(false) +// Step D — disavowal statement +const disavowalTree = ref('') +const disavowalVerified = ref(null) +const disavowalBuilt = ref(false) + const operationalNicknames = computed(() => keyInventory.value.filter(k => !k.isInception).map(k => ({ label: k.nickname, value: k.nickname })), ) @@ -56,7 +69,12 @@ function handleReconstruct() { function handleRevoke() { const name = revokeNickname.value - if (!name) return + const doc = activeDoc.value + if (!name || !doc) return + // Capture the key's identity BEFORE removing it (mirrors upstream: run + // `xid key find name` + `digest` ahead of `key remove`). + const info = keyDisavowalInfo(doc, name) + if (info) disavowedKeys.value = [...disavowedKeys.value, info] if (removeActiveKeyByNickname(name)) revokeDone.value = true } @@ -65,7 +83,30 @@ function handleRekey() { const side = addOperationalKeyToActive(`${base}-may2026`, 'Ed25519', [ Privilege.Auth, Privilege.Sign, Privilege.Elide, Privilege.Access, ]) - if (side) rekeyDone.value = true + if (side) { + replacementKey.value = side + rekeyDone.value = true + } +} + +function handleBuildDisavowal() { + const doc = activeDoc.value + const signer = replacementKey.value + if (!doc || !signer || disavowedKeys.value.length === 0) return + const today = new Date() + const stamp = today.toISOString().slice(0, 10).replace(/-/g, '') + const n = disavowedKeys.value.length + const signed = buildSignedDisavowal({ + disavowerXidUr: doc.xid().urString(), + subject: `disavowal-statement-${stamp}`, + statement: `Disavowing signatures from ${n} key${n === 1 ? '' : 's'} during the compromise window`, + reason: 'Key compromise: unauthorized access', + date: today, + keys: disavowedKeys.value, + }, signer.prvKeys) + disavowalTree.value = signed.format() + disavowalVerified.value = signed.hasSignatureFrom(signer.pubKeys) + disavowalBuilt.value = true } function handleAdvance() { @@ -189,11 +230,33 @@ const canFinish = computed(() => operationalBuilt.value)
{{ operationalTree }}
+ +
+

4. Create a signed disavowal statement

+

+ Revoking a key doesn't retroactively invalidate signatures it already made. Amira + publishes a signed statement — structured as a standard edge (§3.1) and signed by her + new key — naming the compromised keys so verifiers can distrust signatures from the + compromise window. She can publish it alongside her XID without adding it as an edge. +

+ +
+ +
{{ disavowalTree }}
+
+
+

Chapter 5 complete 🎉

You've generated operational keys, updated and rotated them, backed up your inception and - SSH keys with SSKR, and recovered from a compromise — all without changing your XID. + SSH keys with SSKR, recovered from a compromise, and published a signed disavowal — all + without changing your XID.

diff --git a/apps/playground/app/utils/xid-tutorial/disavowal.ts b/apps/playground/app/utils/xid-tutorial/disavowal.ts new file mode 100644 index 00000000..b1234411 --- /dev/null +++ b/apps/playground/app/utils/xid-tutorial/disavowal.ts @@ -0,0 +1,52 @@ +import { Envelope } from "@bcts/envelope"; +import { IS_A, SOURCE, TARGET, DATE, NICKNAME } from "@bcts/known-values"; +import { XID, type PrivateKeys, type SigningOptions } from "@bcts/components"; +import type { DisavowedKey } from "./keys"; + +export interface DisavowalInput { + /** UR of the disavowing party's XID identifier (becomes subject + edge source). */ + disavowerXidUr: string; + /** Unique edge subject, e.g. `disavowal-statement-20260505`. */ + subject: string; + statement: string; + reason: string; + date?: Date; + keys: DisavowedKey[]; +} + +/** + * Build + wrap + sign a disavowal statement. Byte-for-byte structural mirror of + * upstream XID-Quickstart §5.5 Step 9: a standard edge (`isA` / `source` / + * `target`, per §3.1) whose `target` is an attestation listing each disavowed + * key (its public keys, `nickname`, and `xidKeyDigest`). Signed by a current, + * non-compromised key (the new attestation key). + */ +export function buildSignedDisavowal(input: DisavowalInput, signer: PrivateKeys): Envelope { + const xid = XID.fromURString(input.disavowerXidUr); + + // The disavowal attestation — this becomes the edge `target`. + let disavowal = Envelope.new(xid) + .addAssertion("disavowalStatement", input.statement) + .addAssertion("disavowalReason", input.reason) + .addAssertion(DATE, (input.date ?? new Date()).toISOString()); + + // Recursively embed each disavowed key as a `disavowedKey` sub-envelope. + for (const k of input.keys) { + const keyEnv = Envelope.new(k.pubKeys) + .addAssertion(NICKNAME, k.nickname) + .addAssertion("xidKeyDigest", k.assertionDigest); + disavowal = disavowal.addAssertionEnvelope(Envelope.newAssertion("disavowedKey", keyEnv)); + } + + // Wrap it in a standard edge with a unique subject + isA/source/target. + const edge = Envelope.new(input.subject) + .addAssertion(IS_A, "signature-disavowal") + .addAssertion(SOURCE, xid) + .addAssertionEnvelope(Envelope.newAssertion(TARGET, disavowal)); + + // The signer is a freshly-rotated key, so it's never SSH-backed here. + const options: SigningOptions | undefined = signer.signingPrivateKey().isSsh() + ? { type: "Ssh", namespace: "envelope", hashAlg: "sha256" } + : undefined; + return edge.signOpt(signer, options); +} diff --git a/apps/playground/app/utils/xid-tutorial/keys.ts b/apps/playground/app/utils/xid-tutorial/keys.ts index 8fcc0a7c..f694ecf7 100644 --- a/apps/playground/app/utils/xid-tutorial/keys.ts +++ b/apps/playground/app/utils/xid-tutorial/keys.ts @@ -1,8 +1,17 @@ import { Key, type Privilege, type XIDDocument } from "@bcts/xid"; -import type { PublicKeys } from "@bcts/components"; +import type { PublicKeys, Digest } from "@bcts/components"; import { generateSideKey, type TutorialScheme } from "./identity"; import type { SideKey } from "./types"; +/** Identifying material for a key being disavowed (§5.5 Step 9). The + * `assertionDigest` is the digest of the key's envelope as it appeared in the + * XID — mirrors upstream `envelope digest $(envelope xid key find name …)`. */ +export interface DisavowedKey { + nickname: string; + pubKeys: PublicKeys; + assertionDigest: Digest; +} + /** Read-model for the key-inventory panel (§5.1). */ export interface KeyInventoryEntry { nickname: string; @@ -73,6 +82,22 @@ export function rotateKey( return side; } +/** + * Capture a key's disavowal info (nickname + public keys + the digest of its + * envelope in the XID) BEFORE it's revoked, so §5.5 can name it in a signed + * disavowal statement. Mirrors the upstream pattern of running + * `xid key find name` + `digest` ahead of `key remove`. + */ +export function keyDisavowalInfo(doc: XIDDocument, nickname: string): DisavowedKey | undefined { + const key = findKeyByNickname(doc, nickname); + if (!key) return undefined; + return { + nickname, + pubKeys: key.publicKeys(), + assertionDigest: key.intoEnvelope().digest(), + }; +} + /** Remove a key by nickname; returns its public keys if it existed (§5.5). */ export function removeKeyByNickname(doc: XIDDocument, name: string): PublicKeys | undefined { const key = findKeyByNickname(doc, name);