ROOTVIELROOTVIEL
← Academy

Module 7 · Labs & experiments

Lab — build a delegation chain

A guided, self-contained lab on synthetic data. You model a capability as a signed statement, chain several delegations where each link is signed by the key named in the link above it, and write a verifier that walks the chain back to a single trusted root. Validating the chain proves who passed authority to whom — not that using it is wise or safe.

Learning objective

Issue a signed root capability, delegate it hop by hop with each link signed by the previous holder, and validate the whole chain end-to-end so it terminates at the trusted root key — the mechanics behind signed, verifiable authority.

Track
Engineering
Module
7 · Labs & experiments
Prerequisites
Digital signatures · Delegation chains · Node.js 16+ (built-in crypto)

Lab goal

This teaching exercise builds a three-hop delegation chain on synthetic data, offline, with disposable keys. A chain is valid when every link is a correctly signed statement and the signatures connect unbroken back to one root key you already trust. You will see that validity is about linkage and authenticity, not about whether the delegated action should happen.

Note

A valid chain answers "did authority flow from the root to this holder by signed steps?" — it does not answer "is the final action correct or safe?" That judgement is always separate.

The model

Root capability
A statement signed by the root key granting some authority to a first delegate key. Its issuer is the root itself (self-issued).
Link (delegation)
A statement signed by holder N that grants authority to holder N+1. It names the parent so a verifier can chain upward.
Chain validity
Every link verifies under the public key of the issuer named in the link above it, and the topmost issuer equals the trusted root public key.

Step by step

  1. 1Generate four key pairs: a root, and delegates A, B, C.
  2. 2Root issues a self-signed root capability naming A as the grantee.
  3. 3A signs a link granting to B; B signs a link granting to C. Each link records issuer public key, subject public key and the capability.
  4. 4Assemble the chain as an ordered list from root down to the final grant.
  5. 5Verify end-to-end: check each link signature under its issuer key, confirm each link issuer matches the previous link subject, and confirm the first issuer is the trusted root.
lab2.js — synthetic delegation chain (Node.js crypto)
const crypto = require('crypto'); const mk = () => crypto.generateKeyPairSync('ed25519'); const pub = (kp) => kp.publicKey.export({ type: 'spki', format: 'der' }).toString('base64'); // Stable serialization: sort keys recursively so the nested cap is actually signed. const canonical = (o) => Buffer.from(JSON.stringify(o, (k, v) => v && typeof v === 'object' && !Array.isArray(v) ? Object.fromEntries(Object.keys(v).sort().map((key) => [key, v[key]])) : v)); const root = mk(), A = mk(), B = mk(), C = mk(); // A signed link: issuer grants 'cap' to subject. function issue(issuerKp, subjectPubB64, cap) { const body = { issuer: pub(issuerKp), subject: subjectPubB64, cap }; const sig = crypto.sign(null, canonical(body), issuerKp.privateKey).toString('base64'); return { body, sig }; } // Root -> A -> B -> C, attenuating the scope each hop (narrower is fine). const chain = [ issue(root, pub(A), { scope: 'vault:lab/*', ops: ['read', 'write'] }), issue(A, pub(B), { scope: 'vault:lab/reports/*', ops: ['read', 'write'] }), issue(B, pub(C), { scope: 'vault:lab/reports/q3', ops: ['read'] }), ]; function verifyChain(chain, trustedRootPubB64) { if (chain.length === 0) return false; if (chain[0].body.issuer !== trustedRootPubB64) return false; // anchor at root for (let i = 0; i < chain.length; i++) { const { body, sig } = chain[i]; const issuerKey = crypto.createPublicKey({ key: Buffer.from(body.issuer, 'base64'), type: 'spki', format: 'der', }); if (!crypto.verify(null, canonical(body), issuerKey, Buffer.from(sig, 'base64'))) return false; if (i > 0 && body.issuer !== chain[i - 1].body.subject) return false; // links connect } return true; } console.log('chain valid:', verifyChain(chain, pub(root))); // Break a link: forge B's grant with C's own key (C did not receive authority from B). const forged = chain.slice(); forged[2] = issue(C, pub(C), { scope: 'vault:lab/*', ops: ['read', 'write'] }); console.log('forged valid:', verifyChain(forged, pub(root)));

Expected result

expected console output
chain valid: true forged valid: false

The honest chain validates: every signature checks out under the issuer named above it, and the top issuer is the trusted root. The forged variant fails because its issuer (C) does not match the subject of the previous link (B granted to C, not C to C) — the links no longer connect, so the chain does not terminate at the root through unbroken signatures.

Common mistakes

  • ▸Forgetting the anchor check: verifying each signature but never confirming the first issuer equals the trusted root accepts any self-consistent chain from any key.
  • ▸Not checking that each link issuer equals the previous link subject — without it, unrelated valid links can be stitched into a chain that never passed through the root.
  • ▸Trusting a public key carried inside the message as "the root". The root key must come from your own trust store, not from the chain being verified.
  • ▸Reading chain validity as permission. A valid chain shows authority flowed by signed steps; whether the final capability should be exercised is a separate policy decision.
Note

This lab checks linkage and signatures only. It does not yet enforce that each hop narrows (attenuates) the scope — that is the next lab, scope attenuation.

Exercise

Note

Add an expiry (exp, a unix timestamp) to each link body and extend verifyChain so a chain is rejected if any link is expired relative to a supplied "now". Where in the loop does the check belong, and why must every link be checked rather than only the last?

Expected direction: Add the check inside the per-link loop, after the signature verifies: reject if body.cap.exp < now (or if a link exp is later than its parent exp, since a child cannot outlive its parent). Every link must be checked because authority is only as valid as the weakest link in the path — an expired intermediate means the authority it passed on is no longer live, regardless of whether later links are still in date.

Check your understanding

What two conditions make a delegation chain valid end-to-end?
Every link verifies under the public key of the issuer named above it, and the links connect so the topmost issuer equals the trusted root public key held in your own trust store.
Why must the root public key come from your trust store rather than from the chain?
Otherwise an attacker supplies both the chain and its claimed root; anchoring to a key you independently trust is what prevents a fully self-consistent but illegitimate chain from being accepted.
Does a valid chain mean the final action is safe to perform?
No. It proves authority flowed from the root by signed steps. Whether exercising that authority is correct or safe is a separate decision.

References

  1. [1]RFC 8032 — EdDSA / Ed25519
  2. [2]Node.js crypto — key objects and sign/verify
  3. [3]NIST SP 800-162 — Guide to Attribute Based Access Control (ABAC)

References point to public, well-known technical sources for further study.

Related articles