vouchsafe
Version:
Self-verifying identity and offline trust verification for JWTs, including attestations, vouches, revocations, and multi-hop trust chains.
177 lines (151 loc) • 5.69 kB
JavaScript
import fs from 'fs';
import { Command } from 'commander';
import { Identity } from '../index.mjs';
const program = new Command();
program
.name('create_vouchsafe_token')
.description(
'Create a Vouchsafe token from an identity file and claims.\n' +
'Token types:\n' +
' * attest (default) - issue an attestation\n' +
' * vouch - vouch for an existing token (-t or -T required)\n' +
' * revoke - revoke a previous vouch (-t or -T required)\n' +
'Claims may be provided via a JSON file (-f) and/or key=value pairs (-c).\n' +
'Use -p to set a purpose (repeatable; for attest/vouch).\n' +
'Expiration defaults to 1 day; -e 0 disables exp.\n' +
'Outputs the JWT to stdout by default or to a file with -o.\n'
)
.option('-i, --identity <file>', 'Path to identity JSON file (required)')
.option('-f, --claims <file>', 'Path to claims JSON file')
.option('-c, --claim <key=value>', 'Additional claim (repeatable)', collectClaims, {})
.option('-p, --purpose <purpose>', 'Purpose for the token (repeatable)', collectStrings, [])
.option('-e, --expires <seconds>', 'Expiration in seconds (default 86400, 0 = no exp)')
.option('-o, --output <file>', 'Write token to this file instead of stdout')
.option('-q, --quiet', 'Suppress warnings and status output')
.option('-v, --verbose', 'Give extra status output')
// token subject inputs (for vouch/revoke)
.option('-t, --token-file <file>', 'Subject token file (for --vouch/--revoke)')
.option('-T, --token <string>', 'Subject token string (for --vouch/--revoke)')
// type switches
.option('--attest', 'Create an attestation token (default)')
.option('--vouch', 'Create a vouch token (requires -t or -T)')
.option('--revoke', 'Create a revoke token (requires -t or -T)')
.helpOption('-h, --help', 'Display help');
program.parse(process.argv);
const opts = program.opts();
function verbose(...args) {
if (opts.verbose) {
console.error(...args);
}
};
function error(...args) {
console.error(...args);
};
if (!opts.identity) {
error('!!! Identity file (-i) is required\n');
program.help();
process.exit(1);
}
function collectClaims(value, previous) {
const idx = value.indexOf('=');
if (idx === -1) {
throw new Error(`Invalid claim: ${value}. Expected key=value`);
}
const key = value.slice(0, idx).trim();
const raw = value.slice(idx + 1);
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
parsed = raw;
}
previous[key] = parsed;
return previous;
}
function collectStrings(value, previous) {
previous.push(value);
return previous;
}
function parseClaimsFile(filename) {
const txt = fs.readFileSync(filename, 'utf8');
try {
const obj = JSON.parse(txt);
if (obj && typeof obj === 'object' && !Array.isArray(obj)) return obj;
} catch (e) {
// fall through
}
throw new Error(`Claims file must be a JSON object: ${filename}`);
}
function writeOut(pathOrStdout, data) {
if (!pathOrStdout || pathOrStdout === '-') {
process.stdout.write(data);
if (!data.endsWith('\n')) process.stdout.write('\n');
return;
}
fs.writeFileSync(pathOrStdout, data + (data.endsWith('\n') ? '' : '\n'), 'utf8');
}
function toSeconds(val, def = 86400) {
if (val === undefined || val === null) return def; // default 1 day
const n = Number(val);
if (!Number.isFinite(n) || n < 0) {
throw new Error(`Invalid expires value: ${val}. Use seconds (0 = no exp).`);
}
return Math.floor(n);
}
function loadSubjectToken() {
if (opts.token) return opts.token;
if (opts.tokenFile) return fs.readFileSync(opts.tokenFile, 'utf8').trim();
throw new Error('Subject token required: use -t <file> or -T <token>');
}
function resolveAction() {
const picks = [opts.attest ? 'attest' : null, opts.vouch ? 'vouch' : null, opts.revoke ? 'revoke' : null].filter(Boolean);
if (picks.length === 0) return 'attest';
if (picks.length > 1) throw new Error('Choose only one of --attest, --vouch, or --revoke');
return picks[0];
}
(async () => {
try {
const action = resolveAction();
// Load identity
const idJson = JSON.parse(fs.readFileSync(opts.identity, 'utf8'));
const identity = await Identity.from(idJson);
// Merge claims: file first, then -c overrides
const claims = {};
if (opts.claims) Object.assign(claims, parseClaimsFile(opts.claims));
if (opts.claim) Object.assign(claims, opts.claim);
// Purpose: string or array (attest & vouch)
if (action === 'attest' || action === 'vouch') {
if(opts.purpose && opts.purpose.length) {
claims.purpose = opts.purpose.length === 1 ? opts.purpose[0] : opts.purpose;
} else if (!opts.quiet) {
error('Warning: no purpose defined, which means all permissions granted')
}
}
// Timestamps
const iat = Math.floor(Date.now() / 1000);
claims.iat ??= iat;
const expSeconds = toSeconds(opts.expires, 86400);
if (expSeconds > 0) {
claims.exp = iat + expSeconds;
}
let token;
if (action === 'attest') {
token = await identity.attest(claims);
} else if (action === 'vouch') {
const subject = loadSubjectToken();
token = await identity.vouch(subject, claims);
} else if (action === 'revoke') {
const subject = loadSubjectToken();
token = await identity.revoke(subject, claims);
}
// Output
writeOut(opts.output, token);
if (opts.output && opts.output !== '-' && !opts.quiet) {
verbose(`Saved token to: ${opts.output}`);
}
} catch (err) {
error('Error:', err?.message || err);
process.exit(1);
}
})();