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..850a0f15 --- /dev/null +++ b/apps/playground/app/components/xid-tutorial/StepKeyCompromise.vue @@ -0,0 +1,274 @@ + + + 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/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/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..f694ecf7 --- /dev/null +++ b/apps/playground/app/utils/xid-tutorial/keys.ts @@ -0,0 +1,138 @@ +import { Key, type Privilege, type XIDDocument } from "@bcts/xid"; +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; + 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; +} + +/** + * 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); + 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 {