UNPKG

vouchsafe

Version:

Self-verifying identity and offline trust verification for JWTs, including attestations, vouches, revocations, and multi-hop trust chains.

704 lines (592 loc) 26.6 kB
import { verifyJwt, decodeJwt } from './jwt.mjs'; import { validateVouchToken, verifyVouchToken, hashJwt, isBurnToken, isRevocationToken } from './vouch.mjs'; // this just gives us a stable identifier for lookup. // we use it in a lot of places, so this gives us // a consistent formulation of the lookup string. function tokenId(iss, jti) { if (iss.length < 70) { throw new Error('invalid iss, too short: ', iss); } if (jti.length != 36) { throw new Error('invalid jti, too short:', jti); } return iss + "/" + jti; } export async function decodeToken(raw_token) { let token_obj = { token: raw_token }; token_obj.decoded = await validateVouchToken(raw_token); token_obj.hash = await hashJwt(raw_token, "sha256"); return token_obj; } async function prepareTclean(rawTokens, trustedIssuers) { const validated = []; const seenIssJTI = new Set(); const burnedIdentities = new Set(); let foundTrustedIssuerToken = false; //console.log('raw', JSON.stringify(rawTokens, undefined, 4)); // Step 1: Collect our valid tokens, validate, decode, compute token hash. // Deduplicate on jti. for (const raw of rawTokens) { let current_token; try { current_token = await decodeToken(raw); } catch(e) { console.error('Unable to decode token: ', e); continue; } if (trustedIssuers.hasOwnProperty(current_token.decoded.iss)) { foundTrustedIssuerToken = true; } //console.log('decoded: ', current_token); const issjti = tokenId(current_token.decoded.iss, current_token.decoded.jti) //console.log('issjti: ', issjti); if (seenIssJTI.has(issjti)) { continue; } seenIssJTI.add(issjti); // if we are here, the token decoded correctly and validated // so add it to our valid token list validated.push(current_token); // if the token is a burn token, make note of it, we'll need it in a minute. if (isBurnToken(current_token.decoded)) { burnedIdentities.add(current_token.decoded.iss); } } //console.log(validated); // // if we did not find a single trusted issuer in our full token set, // there is no way for DAG evaluation to succeed, so every token chain // is effectively invalidated. If we detect this state, we throw an // error immediately, before we do any more work. if (!foundTrustedIssuerToken) { throw new Error('No Trusted Issuer tokens found in token set, evaluation can not succeed'); } // Pass 3: Start building our graph structure - filling in by_jti and by_sub for all non-revocation tokens. const tokenGraph = { by_iss_jti: {}, by_subject: {}, burned_identities: burnedIdentities }; const revocations = []; // loop over our valid tokens for (const v of validated) { const decoded = v.decoded; // Burn tokens always included, never filtered out const isBurn = isBurnToken(decoded); // If issuer is burned and this is not the burn token, skip it if (burnedIdentities.has(decoded.iss) && !isBurn) { continue; } if (isRevocationToken(decoded)) { revocations.push(v); continue; } const token_id = tokenId(decoded.iss, decoded.jti); // Index by jti tokenGraph.by_iss_jti[token_id] = v; const subject_iss = decoded.vch_iss || decoded.iss; // Index by sub for graph construction and revocation let iss_sub = tokenId(subject_iss, decoded.sub); // a token that refers to itself should not be form an edge, so should not be in by_subject if (!isBurn && decoded.sub != decoded.jti) { if (!tokenGraph.by_subject[iss_sub]) { tokenGraph.by_subject[iss_sub] = []; } tokenGraph.by_subject[iss_sub].push(v); } } // Pass 4: Apply revocations after full indexing for (const r of revocations) { const revoke_token = r.decoded; const target_id = tokenId(revoke_token.vch_iss, revoke_token.sub); let revoke_candidates = []; // get all the tokens that reference this iss/sub let vouch_candidates = tokenGraph.by_subject[target_id]; if (vouch_candidates && vouch_candidates.length > 0) { revoke_candidates = [...vouch_candidates]; } // we might be revoking an attestation, in which case // we need to look it up directly let attest_candidate = tokenGraph.by_iss_jti[target_id]; if (attest_candidate) { revoke_candidates.push(attest_candidate); } if (!revoke_candidates || revoke_candidates.length === 0) { continue; } // if our revokes field have to revoke all - we find all if (revoke_token.revokes === "all") { const remaining = []; for (const tok of revoke_candidates) { const candidate_token = tok.decoded; // we don't revoke burns or other revokes. if (isBurnToken(candidate_token) || isRevocationToken(candidate_token)) { continue; } const candidate_id = tokenId(candidate_token.iss, candidate_token.jti); // We have to check multiple items to know if we can revoke. // First, revoke tokens can only revoke from the same issuer for the same subject. if ( candidate_token.iss === revoke_token.iss && candidate_token.sub === revoke_token.sub && candidate_token.kind === "vch:vouch" && revoke_token.vch_iss === candidate_token.vch_iss && revoke_token.vch_sum === candidate_token.vch_sum) { delete tokenGraph.by_iss_jti[candidate_id]; } else { remaining.push(tok); } } tokenGraph.by_subject[target_id] = remaining; } else { const remaining = []; for (const tok of revoke_candidates) { const candidate_token = tok.decoded; const candidate_hash = tok.hash; if (isBurnToken(candidate_token) || isRevocationToken(candidate_token)) { continue; } const candidate_id = tokenId(candidate_token.iss, candidate_token.jti); // First, revoke tokens can only revoke from the same issuer for the same subject. if ( candidate_token.jti === revoke_token.revokes && candidate_token.iss === revoke_token.iss && candidate_token.sub === revoke_token.sub ) { // They must also have a matching sub, vch_iss, and vch_sum (to be sure they are referencing the correct token) if (candidate_token.kind === "vch:vouch" && revoke_token.vch_iss === candidate_token.vch_iss && revoke_token.vch_sum === candidate_token.vch_sum) { delete tokenGraph.by_iss_jti[candidate_id]; } else if (candidate_token.kind === "vch:attest" && revoke_token.vch_iss === candidate_token.iss && revoke_token.vch_sum === candidate_hash) { // if revoking an attest, the vch_iss matches iss and vch_sum matches the hash of the canditate token delete tokenGraph.by_iss_jti[candidate_id]; } else { remaining.push(tok); } } else { remaining.push(tok); } } tokenGraph.by_subject[target_id] = remaining; } } //console.log(JSON.stringify(tokenGraph, undefined, 4)); return tokenGraph; } // -------------------------------------------- // Build consistent subject identifier // This is used for graph traversal. // -------------------------------------------- function subjectIdOf(decoded) { const subjectIssuer = decoded.vch_iss || decoded.iss; const subjectJti = decoded.sub; return tokenId(subjectIssuer, subjectJti); } // -------------------------------------------- // Purpose extraction // Three modes: // mode: "any" — parent has no purpose field (no attenuation) // mode: "empty" — purpose: "" (delegates nothing) // mode: "set" — explicit space-separated list // -------------------------------------------- function purposeModeFromDecoded(decoded) { // CASE A: purpose omitted → treat as S_Any if (!decoded.hasOwnProperty("purpose")) { return { mode: "any", set: null }; } const raw = decoded.purpose; // CASE B: explicit empty string if (typeof raw === "string" && raw.trim() === "") { return { mode: "empty", set: new Set() }; } // CASE C: explicit space-separated list const parts = raw.trim().split(/\s+/); return { mode: "set", set: new Set(parts) }; } // -------------------------------------------- // Intersect incoming purposeSet with parent’s // delegation purpose model. // Also handles S_Any, S_Empty. // Returns null if delegation stops. // -------------------------------------------- function attenuatePurposes(child, parent) { // // Case 1: Parent = ANY → pass through child unchanged // if (parent.mode === "any") { // clone child if (child.mode === "any") { return { mode: "any", set: null }; } if (child.mode === "empty") { return { mode: "empty", set: new Set() }; } return { mode: "set", set: new Set(child.set) }; } // // Case 2: Parent = EMPTY → no delegation allowed at all // if (parent.mode === "empty") { return { mode: "empty", set: new Set() }; } // // Case 3: Parent = SET // if (parent.mode === "set") { // If child = ANY → result is just the parent’s set if (child.mode === "any") { return { mode: "set", set: new Set(parent.set) }; } // If child = EMPTY → stays empty if (child.mode === "empty") { return { mode: "empty", set: new Set() }; } // child = SET → intersect the two const out = new Set(); for (const p of child.set) { if (parent.set.has(p)) { out.add(p); } } if (out.size === 0) { return { mode: "empty", set: new Set() }; } return { mode: "set", set: out }; } throw new Error("Invalid purpose model: " + parent.mode); } // -------------------------------------------- // Create visit key encoding (issuer/jti + purpose mode) // Ensures evaluator does not revisit identical evaluation states. // -------------------------------------------- function makeVisitKey(decoded, purposeSetOrModel) { const tokenId = decoded.iss + "/" + decoded.jti; if (purposeSetOrModel === "ANY") { return tokenId + "|ANY"; } if (purposeSetOrModel === "EMPTY") { return tokenId + "|EMPTY"; } // It's a concrete Set of purposes const arr = Array.from(purposeSetOrModel).sort(); return tokenId + "|" + arr.join(","); } function vouchsafeEvaluate(trustGraph, startToken, trustedIssuers, requiredPurposes, options = {}) { // Algorithm: // We perform a breadth–first search (BFS) over (token, purpose-set) states, // starting from the leaf token. Each queue entry represents “we have a concrete // path from the leaf to this token, with these effective purposes after all // attenuation so far”. On each step we: (1) check whether the current token’s // issuer is a trusted root and, if so, whether the accumulated purposes satisfy // the caller’s requirements; (2) if not done and within maxDepth, look up all // vouch tokens that reference this token as their subject, apply their purpose // rules to produce a new purpose-set, and enqueue those as new states. The // visited set ensures we never revisit the same token with the same effective // purposes, preventing cycles and redundant work. // ============================================================ // OPTION DEFAULTING // ============================================================ if (typeof options.returnAllValidChains === "undefined") { options.returnAllValidChains = false; } if (typeof options.maxDepth === "undefined") { options.maxDepth = undefined; // no limit } // ============================================================ // INITIAL LEAF PURPOSE HANDLING // ============================================================ const startDecoded = startToken.decoded; const startPurposeModel = purposeModeFromDecoded(startDecoded); let initialPurposes; // Compute the subject ID (iss/sub pair) of the starting token const startSubjectId = subjectIdOf(startDecoded); // ============================================================ // BFS INITIALIZATION // ============================================================ const queue = []; queue.push({ token: startToken, purposes: startPurposeModel, chain: [ startToken ], depth: 0 }); // prepareTclean will always produce a DAG, but if for some reason // we are given a bad trust graph as input, the visited set ensures we don't loop. const visited = new Set(); // mutation explicitly controlled const validChains = []; // collect full valid chains when enabled // ============================================================ // BFS LOOP // ============================================================ while (queue.length > 0) { const frame = queue.shift(); const currentToken = frame.token; const currentDecoded = currentToken.decoded; const currentPurposes = frame.purposes; const currentChain = frame.chain; const currentDepth = frame.depth; const currentIssuer = currentDecoded.iss; // tokens from burned identities should not have made it onto the graph, // so this shouldn't happen, but just in case it slips by somehow: immediately skip if identity is burned if (trustGraph.burned_identities.has(currentDecoded.iss)) { continue; } // ======================================================== // TRUST ROOT CHECK // ======================================================== if (trustedIssuers.hasOwnProperty(currentIssuer)) { const allowedRootPurposes = new Set(trustedIssuers[currentIssuer]); const effectivePurposes = new Set(); // Determine which purposes survive at the trust root const iter = currentPurposes.set.values(); while (true) { const next = iter.next(); if (next.done) break; const p = next.value; if (allowedRootPurposes.has(p)) { effectivePurposes.add(p); } } // If the root grants nothing → not valid if (effectivePurposes.size > 0) { let chainSatisfiesRequirements = true; // =================================================================================== // If we have a requiredPurposes, then ALL must be present for this chain to be valid // =================================================================================== if (Array.isArray(requiredPurposes) && requiredPurposes.length > 0) { // QUICK FAIL: // If the effective set is smaller than the number of required // purposes, it is impossible for all to be present. if (effectivePurposes.size < requiredPurposes.length) { chainSatisfiesRequirements = false; } else { // FULL CHECK: // Our effective set has the same number or more purposes, so we need to // ensure that every required purpose is present in effectivePurposes. for (let i = 0; i < requiredPurposes.length; i++) { const req = requiredPurposes[i]; if (!effectivePurposes.has(req)) { chainSatisfiesRequirements = false; break; } } } } if (chainSatisfiesRequirements) { if (options.returnAllValidChains === true) { // NOTE (Mutation): // Store a *copy* of the chain because BFS will mutate future frames. validChains.push({ chain: currentChain.slice(), purposes: Array.from(effectivePurposes), trustRoot: currentIssuer }); } else { return { valid: true, chains: [ { chain: currentChain.slice(), purposes: Array.from(effectivePurposes), trustRoot: currentIssuer } ], effectivePurposes: Array.from(effectivePurposes), subjectToken: startToken, trustRoot: currentIssuer }; } } } } // ======================================================== // MAX DEPTH CHECK // ======================================================== if (typeof options.maxDepth === "number" && currentDepth >= options.maxDepth) { continue; } // ======================================================== // UPWARD TRAVERSAL // ======================================================== // For upward traversal, the "subject" we are looking for is the // current token itself: any vouch tokens whose subject is this // token's (iss, jti) pair. // // prepareTclean() indexes by_subject using that (iss, jti) of the // *token being vouched for*, so we must use the current token's // own identity here. const subjectId = currentDecoded.iss + "/" + currentDecoded.jti; const parents = trustGraph.by_subject[subjectId]; if (!parents) { continue; // No further edges upward } for (let i = 0; i < parents.length; i++) { const parentToken = parents[i]; const parentDecoded = parentToken.decoded; // Only vouch tokens propagate upward if (parentDecoded.kind !== "vch:vouch") { continue; } const parentPurposeModel = purposeModeFromDecoded(parentDecoded); const nextPurposes = attenuatePurposes(currentPurposes, parentPurposeModel); if (!nextPurposes) { continue; // Parent wipes out all purposes } // Build visit key based on issuer + jti + purpose-mode let visitKey; if (parentPurposeModel.mode === "any") { visitKey = makeVisitKey(parentDecoded, "ANY"); } else if (parentPurposeModel.mode === "empty") { visitKey = makeVisitKey(parentDecoded, "EMPTY"); } else { visitKey = makeVisitKey(parentDecoded, nextPurposes); } if (visited.has(visitKey)) { continue; } // NOTE (Mutation): // Marking visited before enqueueing ensures we never requeue visited.add(visitKey); // NOTE (Mutation): // Construct new chain frame by cloning old chain const nextChain = currentChain.slice(); nextChain.push(parentToken); // Enqueue upward step queue.push({ token: parentToken, purposes: nextPurposes, chain: nextChain, depth: currentDepth + 1 }); } } // ============================================================ // AGGREGATE / RETURN RESULTS // ============================================================ // At this point, any chain in validChains has already satisfied // requiredPurposes (if provided). We do NOT aggregate permissions // across chains; each chain is evaluated independently and its // purposes are self-contained. The caller can union them if they // wish, but that is application policy, not core trust logic. if (validChains.length === 0) { return { valid: false, chains: [], effectivePurposes: [] }; } // We treat the purposes on the first valid chain as the effective // purposes for this evaluation. This is conservative: it never // grants more than a single justified chain supports. const primaryChain = validChains[0]; return { valid: true, chains: validChains, effectivePurposes: Array.from(primaryChain.purposes), // if you’ve added trustRoot on each chain object, this gives the // caller enough data to know which issuer granted these purposes. subjectToken: startToken, trustRoot: primaryChain.trustRoot }; } // --------------------------------------------------------------------------- // validateTrustChain(tokens, startToken, trustedIssuers, purposes, options = {}) // // This is the canonical entrypoint for Vouchsafe trust validation. // Steps: // 1. Clean and normalize the raw token set (prepareTclean) // 2. Evaluate the trust graph starting from startToken // 3. Return the result of vouchsafeEvaluate // // Parameters: // tokens : Array of raw JWT strings // startToken : The parsed { token, decoded, hash } object for the // leaf token whose trust we want to validate. // trustedIssuers : Map of trusted roots -> allowed purposes // e.g. { "urn:vouchsafe:root.ab...": ["msg-signing", ...] } // purposes : Optional array of purposes to filter final output // options : evaluator behavior controls: // maxDepth : Optional integer limiting chain depth // // Returns: // { // valid: boolean, // chains: [...], // effectivePurposes: [...] // } // // --------------------------------------------------------------------------- export async function validateTrustChain(tokens, givenStartToken, trustedIssuers, purposes, options = {}) { // our Token set must include the start token so the graph can be built. // if start token is already present → safe to use as-is - otherwise copy + append const tokenSet = tokens.includes(givenStartToken) ? tokens : [...tokens, givenStartToken]; // ----------------------------------------------------------------------- // Step 1: // Clean and normalize the token set. // // prepareTclean: // - decodes tokens and removes any invalid tokens from the list // - validates signatures and required fields // - deduplicates tokens // - detects burn tokens // - removes tokens issued by burned identities // - indexes tokens by (iss/jti) and by subject // - pre-applies revocation tokens // // This produces a fully self-contained trustGraph suitable // for pure, offline ZI-CG evaluation. // ----------------------------------------------------------------------- let trustGraph; try { trustGraph = await prepareTclean(tokenSet, trustedIssuers); } catch(e) { // an error here likely means we had bad tokens or we don't have // any trustedIssuer issued tokens. Either way, we can not // continue console.error('Error encountered while preparing Tclean: ', e); return { valid: false, chains: [], effectivePurposes: [] }; } // decode the given start token so we can ensure it's still part of the // cleaned token graph let startToken = await decodeToken(givenStartToken); // load our start token from the graph let found_start_token = trustGraph.by_iss_jti[tokenId(startToken.decoded.iss, startToken.decoded.jti)]; // if we didn't find our start token, // it was likely revoked or burned. Fail immediately. if (typeof found_start_token != 'object') { return { valid: false, chains: [], effectivePurposes: [] }; }; // ----------------------------------------------------------------------- // Step 2: // Evaluate trust using the pure evaluator. // // vouchsafeEvaluate: // - performs BFS upward through vouch edges // - applies purpose attenuation rules // - respects revocation pruning already performed in Step 1 // - checks root trust constraints // - collects all valid root-terminating chains // - returns union-of-capabilities from roots // // No network access, no external state, no online resolution. // A perfect ZI-CG evaluation. // ----------------------------------------------------------------------- const result = vouchsafeEvaluate( trustGraph, found_start_token, trustedIssuers, purposes, options ); // ----------------------------------------------------------------------- // Step 3: // Return final evaluation output. // // This includes: // valid → boolean: whether any trusted issuer granted a purpose // chains → all valid chains discovered // effectivePurposes → all purposes granted across all valid chains // ----------------------------------------------------------------------- return result; }