UNPKG

vouchsafe

Version:

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

192 lines (164 loc) 5.45 kB
import { SignJWT, jwtVerify, decodeJwt as joseDecodeJwt, importPKCS8, importSPKI } from 'jose'; import { toBase64, fromBase64 } from './utils.mjs'; import { verifyUrnMatchesKey } from './urn.mjs'; /** * Create a Vouchsafe identity-bound JWT * @param {string} iss - URN of the issuer (e.g. "urn:vouchsafe:alice.xxxxxx") * @param {string} iss_key - base64-encoded public key matching the URN * @param {Uint8Array|string} privateKey - Private key for signing (raw or PEM) * @param {object} claims - Custom JWT claims * @param {object} [options] - Optional fields (e.g., exp, jti) * @returns {Promise<string>} - Signed JWT string */ export async function createJwt(iss, iss_key, privateKey, claims = {}, options = {}) { const valid = await verifyUrnMatchesKey(iss, iss_key); if (!valid) { throw new Error('Provided iss_key does not match issuer URN'); } //console.warn("privateKey", privateKey); const key = await toPrivateKey(privateKey); //console.warn("XXXXXXXXXXXXXXXXXXXXXXXXXkey", key); const now = Math.floor(Date.now() / 1000); let iat; let nbf; // if claims.iat is a number, set it if (typeof claims.iat == 'number') { iat = claims.iat; } else if (claims.iat !== null) { // if claims.iat is explicitly null, we don't set iat. otherwise we set it to now. iat = now; } // same as above. only set nbf automatically if it wasn't provided at all if (typeof claims.nbf == 'number') { nbf = claims.nbf; } else if (claims.nbf !== null) { nbf = now; } const payload = { ...claims, iss, iss_key, iat, nbf, }; if (options.exclude_iss_key) { delete payload.iss_key; } const jwt = await new SignJWT(payload) .setProtectedHeader({ alg: 'EdDSA' }) .sign(key); return jwt; } /** * Verify a Vouchsafe JWT * @param {string} token - JWT string to verify * @param {object} [opts] * - pubKeyOverride: Uint8Array or PEM string * - verifyIssuerKey: boolean (default: true) * @returns {Promise<object>} - Decoded payload if valid * @throws on verification failure */ export async function verifyJwt(token, opts = {}) { const payload = joseDecodeJwt(token); if (!payload || !payload.iss) throw new Error("Invalid JWT payload"); const pubKey = opts.pubKeyOverride ? await toPublicKey(opts.pubKeyOverride) : await extractKeyFromPayload(payload); const verifyResult = await jwtVerify(token, pubKey, { algorithms: ['EdDSA'] }); if (opts.verifyIssuerKey !== false && verifyResult.payload.iss_key) { const matches = await verifyUrnMatchesKey(verifyResult.payload.iss, verifyResult.payload.iss_key); if (!matches) throw new Error("iss_key does not match iss URN"); } return verifyResult.payload; } export function decodeJwt(token, { full = false } = {}) { const payload = joseDecodeJwt(token); if (!full) return payload; const [headerB64] = token.split('.'); const header = JSON.parse( new TextDecoder().decode( Uint8Array.from(atob(headerB64), c => c.charCodeAt(0)) ) ); return { payload, header }; } /** * Return only the application-level claims from a decoded token. * Strips out all core and Vouchsafe-specific claims (identity, trust, and control). * * @param {object} decodedToken - The decoded JWT payload * @returns {object} - Object containing only non-Vouchsafe claims */ export function getAppClaims(decodedToken) { if (!decodedToken || typeof decodedToken !== 'object') { return {}; } const coreAndVouchsafeClaims = new Set([ 'iss', 'iss_key', 'jti', 'sub', 'kind', 'iat', 'exp', 'nbf', 'vch_iss', 'vch_sum', 'revokes', 'purpose', 'burns', ]); const appClaims = {}; for (const [key, value] of Object.entries(decodedToken)) { if (!coreAndVouchsafeClaims.has(key)) { appClaims[key] = value; } } return appClaims; } // -- Internals -- async function toPrivateKey(input) { if (typeof input === 'string' && input.includes('BEGIN')) { return await importPKCS8(input, 'EdDSA'); } const pem = toPem(input, 'PRIVATE'); //console.warn("pemkey", pem); return await importPKCS8(pem, 'EdDSA'); } async function toPublicKey(input) { if (typeof input === 'string' && input.includes('BEGIN')) { return await importSPKI(input, 'EdDSA'); } const pem = toPem(input, 'PUBLIC'); return await importSPKI(pem, 'EdDSA'); } async function extractKeyFromPayload(payload) { if (!payload.iss_key) throw new Error("Missing iss_key for verification"); return await toPublicKey(payload.iss_key); } function toPem(input, type = 'PRIVATE') { if (typeof input === 'string') { if (input.includes('BEGIN')) { return input; // already PEM } // assume it's base64 already (no re-encoding) return `-----BEGIN ${type} KEY-----\n${chunk(input)}\n-----END ${type} KEY-----`; } if (input instanceof Uint8Array) { const b64 = toBase64(input); return `-----BEGIN ${type} KEY-----\n${chunk(b64)}\n-----END ${type} KEY-----`; } throw new Error(`Unsupported key input type: ${typeof input}`); } function chunk(str, len = 64) { return str.match(new RegExp(`.{1,${len}}`, 'g')).join('\n'); }