vouchsafe
Version:
Self-verifying identity and offline trust verification for JWTs, including attestations, vouches, revocations, and multi-hop trust chains.
303 lines (262 loc) • 9.86 kB
JavaScript
import fs from 'fs';
import path from 'path';
import { Command } from 'commander';
import {
validateVouchToken,
validateTrustChain,
} from '../index.mjs'; // same import style as your other CLI
const program = new Command();
let status = (...args) => console.error(...args);
program
.name('verify_vouchsafe_token')
.description(
'Verify a Vouchsafe token.\n\n' +
'Default: validate a single token (signature, URN binding, timestamps).\n' +
'Extended (-E): require trust for -p purpose(s) using a trusted set and optional extra tokens.'
)
.option('-q, --quiet', 'Suppress warnigns and status output')
.option('-v, --verbose', 'Give extra status output')
.option('-t, --token-file <filename>', 'File containing one or more tokens (first = subject; rest = extra)', collect, [])
.option('-T, --token <tokenstring>', 'Token string (first seen = subject; rest = extra)', collect, [])
.option('-O, --output <format>', 'Output format: json | unix', /^(json|unix)$/i)
.option('-f, --field <dotpath>', 'Output only this field (may be repeated)', collect, [])
.option('-E, --extended', 'Extended verification (require trust for -p purpose)')
.option('-P, --prefix <prefix>', 'prefix to use with unix output, (default vs_)', 'vs_')
.option('--trusted <filename>', 'Trusted issuers/purposes file (JSON or plain text)')
.option('--trusted-issuer <issuer:purpose[,purpose2...]>', 'Inline trusted issuer:purpose(s) (may be repeated)', collect, [])
.option('-p, --purpose <purpose>', 'Purpose to evaluate (may be repeated)', collect, [])
.addHelpText('after', `Examples:
# Basic validation; exit code 0 if valid
verify_vouchsafe_token -t tokens.txt
# Output decoded claims as JSON (only if valid)
verify_vouchsafe_token -T "$TOKEN" -O json
# Output specific fields (one per line)
verify_vouchsafe_token -T "$TOKEN" -f iss -f jti -f email
# Extended verification with trusted issuers file and extra tokens
verify_vouchsafe_token -E -p email-confirmation --trusted trusted.json -t chain.txt -O unix
Trusted file formats:
JSON: { "urn:vouchsafe:alice...": ["email-confirmation","webhook:order_placed"], "urn:vouchsafe:bob...": ["email-confirmation"] }
Text: urn:vouchsafe:alice... email-confirmation webhook:order_placed
urn:vouchsafe:bob... email-confirmation
`);
program.parse(process.argv);
const opts = program.opts();
let prefix = opts.prefix; // || 'vs_';
function verbose(...args) {
if (opts.verbose) {
console.error(...args);
}
};
function error(...args) {
console.error(...args);
};
(async () => {
try {
// 1) Gather tokens (subject first, then extras)
const allTokens = [];
for (const f of opts.tokenFile || []) {
const { subject, extras } = readTokenFile(f);
if (subject) allTokens.push(subject);
allTokens.push(...extras);
}
for (const s of opts.token || []) {
if (s && s.trim()) allTokens.push(s.trim());
}
if (allTokens.length === 0) {
error('!!! No tokens provided. Use -t <file> and/or -T <tokenstring>.');
program.help({ error: true });
}
const subject = allTokens[0];
const extras = uniqPreserveOrder(allTokens.slice(1));
// 2) Decide verification mode
let valid = false;
let payload = null;
if (opts.extended) {
// Extended requires purposes + trusted configuration
const purposes = Array.from(opts.purpose || []).filter(Boolean);
if (purposes.length === 0) {
error('!!! Extended verification (-E) requires at least one -p <purpose>.');
process.exit(2);
}
const trusted = await loadTrusted(opts.trusted, Array.from(opts.trustedIssuer || []));
// Build token set for chain resolution (include subject too, dedup)
const chainTokens = uniqPreserveOrder([subject, ...extras]);
const result = await validateTrustChain(chainTokens, subject, trusted, purposes);
valid = !!(result && result.valid);
payload = result && result?.subjectToken?.decoded ? result.subjectToken.decoded : null;
} else {
// Basic validation of a single Vouchsafe token
// Validates structure, URN<->key binding, signature, timestamps. Throws on error.
// (Same call shown in the README quick validation example.)
payload = await validateVouchToken(subject);
valid = !!payload;
}
// 3) Output rules
if (!valid) {
// MUST print nothing on failure (unless verbose is on), and return non-zero
verbose('Token is invalid')
process.exit(1);
}
const fields = Array.from(opts.field || []);
if (fields.length > 0) {
// Output *only* the requested fields, one per line, in the order specified
for (const p of fields) {
const v = getByPath(payload, p);
if (v === undefined || v === null) {
process.stdout.write('\n'); // missing -> empty line
} else if (typeof v === 'object') {
// Objects/arrays: print compact JSON
process.stdout.write(`${JSON.stringify(v)}\n`);
} else {
process.stdout.write(String(v) + '\n');
}
}
process.exit(0);
}
if (/^json$/i.test(opts.output)) {
process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
} else if (/^unix$/i.test(opts.output)) {
// Flatten to vs_<path_with_dots_replaced_by_underscores>=value
const flat = flatten(payload);
for (const [k, v] of Object.entries(flat)) {
const name = prefix + k.replaceAll('.', '_');
const val = (typeof v === 'object') ? JSON.stringify(v) : String(v);
process.stdout.write(`${name}=${val}\n`);
}
} else {
// No output requested -> just exit code 0 unless we are told to be verbose
verbose('Token is valid')
}
process.exit(0);
} catch (err) {
// On any thrown error during validation, do not output data, return non-zero
if (!opts.quiet) error('Error:', err?.message || err);
process.exit(1);
}
})();
/* ------------------------ helpers ------------------------ */
function collect(value, previous) {
previous.push(value);
return previous;
}
function readTokenFile(filename) {
const raw = fs.readFileSync(filename, 'utf8');
// Accept tokens split by any whitespace; ignore blank lines and lines starting with '#'
const lines = raw
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l && !l.startsWith('#'));
// If the file is a single long blob with whitespace, split it further; otherwise each nonempty line is a token
const tokens = lines.length === 1 ? lines[0].split(/\s+/).filter(Boolean) : lines;
const subject = tokens[0] || null;
const extras = tokens.slice(1);
return { subject, extras };
}
async function loadTrusted(filePath, inlinePairs) {
const map = Object.create(null);
// 1) From --trusted file (JSON object OR space-separated text)
if (filePath) {
const text = fs.readFileSync(filePath, 'utf8').trim();
let parsed = null;
try {
parsed = JSON.parse(text);
} catch {
parsed = null;
}
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
// JSON: { urn: [purpose, ...] } or { urn: "space separated" }
for (const [urn, v] of Object.entries(parsed)) {
addTrusted(map, urn, normalizePurposes(v));
}
} else {
// Plain text: "urn purpose1 purpose2 ..."
const lines = text.split(/\r?\n/);
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const parts = trimmed.split(/\s+/);
const urn = parts.shift();
addTrusted(map, urn, parts);
}
}
}
// 2) From repeated --trusted-issuer <urn:purpose[,purpose2,...]>
for (const pair of inlinePairs) {
const s = String(pair);
const idx = s.indexOf(':');
if (idx === -1) continue;
const urn = s.slice(0, idx).trim();
const purp = s.slice(idx + 1).trim();
const list = purp.split(',').map((p) => p.trim()).filter(Boolean);
addTrusted(map, urn, list);
}
return map;
}
function addTrusted(map, urn, purposes) {
if (!urn || !purposes || purposes.length === 0) return;
if (!map[urn]) map[urn] = [];
for (const p of purposes) {
if (!map[urn].includes(p)) map[urn].push(p);
}
}
function normalizePurposes(v) {
if (Array.isArray(v)) return v.filter(Boolean).map(String);
if (typeof v === 'string') return v.split(/\s+/).filter(Boolean);
return [];
}
function uniqPreserveOrder(arr) {
const seen = new Set();
const out = [];
for (const x of arr) {
if (!seen.has(x)) {
seen.add(x);
out.push(x);
}
}
return out;
}
function getByPath(obj, dotpath) {
try {
const parts = String(dotpath).split('.').filter(Boolean);
let cur = obj;
for (const seg of parts) {
const key = isFiniteIndex(seg) ? Number(seg) : seg;
if (cur == null || !(key in cur)) return undefined;
cur = cur[key];
}
return cur;
} catch {
return undefined;
}
}
function isFiniteIndex(s) {
return /^[0-9]+$/.test(s);
}
function flatten(obj, prefix = '') {
const out = {};
const isPrimitive = (v) =>
v == null || typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean';
const helper = (val, pfx) => {
if (isPrimitive(val)) {
out[pfx || 'value'] = val;
return;
}
if (Array.isArray(val)) {
val.forEach((v, i) => helper(v, pfx ? `${pfx}.${i}` : String(i)));
return;
}
if (typeof val === 'object') {
const keys = Object.keys(val);
if (keys.length === 0) {
out[pfx || 'value'] = {}; // empty object
return;
}
for (const k of keys) {
helper(val[k], pfx ? `${pfx}.${k}` : k);
}
}
};
helper(obj, prefix);
return out;
}