vouchsafe
Version:
Self-verifying identity and offline trust verification for JWTs, including attestations, vouches, revocations, and multi-hop trust chains.
295 lines (250 loc) • 10.3 kB
JavaScript
// vouch.mjs
import { decodeJwt } from 'jose';
import { createJwt, verifyJwt } from './jwt.mjs';
import { isValidUUID, toBase64 } from './utils.mjs';
import { sha256, sha512 } from './crypto/index.mjs';
import { verifyUrnMatchesKey, validateIssuerString } from './urn.mjs';
export async function hashJwt(jwt, alg = 'sha256') {
const data = new TextEncoder().encode(jwt);
const digestFn = alg === 'sha512' ? sha512 : sha256;
return digestFn(data).then(bytes => {
const hex = Array.from(new Uint8Array(bytes))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
if (alg == 'sha256') {
return hex;
} else {
return `${hex}.${alg}`;
}
});
}
const VOUCH_KINDS=['vch:attest', 'vch:vouch', 'vch:revoke', 'vch:burn' ];
function isValidKind(kind) {
return VOUCH_KINDS.includes(kind);
}
export async function createVouchToken(subjectJwt, issuer, issuerKeyPair, args = {}) {
const {
publicKey,
privateKey
} = issuerKeyPair;
const subject = decodeJwt(subjectJwt);
// console.log("XXXX", subject);
if (!subject.iss || !subject.jti) throw new Error("Subject JWT must include iss and jti");
const vch_sum = await hashJwt(subjectJwt);
const claims = {
...args,
kind: 'vch:vouch',
sub: subject.jti,
vch_iss: subject.iss,
vch_sum,
jti: args.jti || crypto.randomUUID(),
};
const iss_key = toBase64(publicKey);
return createJwt(issuer, iss_key, privateKey, claims);
}
export async function createAttestation(issuer, issuerKeyPair, args = {}) {
let jti = args.jti || crypto.randomUUID();
const iss_key = toBase64(issuerKeyPair.publicKey);
const claims = {
...args,
kind: 'vch:attest',
jti: jti,
sub: jti
};
return createJwt(issuer, iss_key, issuerKeyPair.privateKey, claims);
}
export async function revokeVouchToken(vouchToken, issuerKeyPair, args = {}) {
let decodedVouchToken = vouchToken;
if (typeof vouchToken == 'string') {
decodedVouchToken = decodeJwt(vouchToken);
}
const claims = {
...args,
jti: args.jti || crypto.randomUUID(),
sub: decodedVouchToken.sub,
revokes: decodedVouchToken.jti,
};
// if we are revoking a vouch, our vch_sum and vch_iss should match the vouch
if (decodedVouchToken.kind == 'vch:vouch') {
claims.vch_sum = decodedVouchToken.vch_sum;
claims.vch_iss = decodedVouchToken.vch_iss;
} else if (decodedVouchToken.kind == 'vch:attest') {
// if we are revoking an attestation, vch_sum and vch_iss match the token itself
claims.vch_sum = await hashJwt(vouchToken);
claims.vch_iss = decodedVouchToken.iss;
}
return await createRevokeToken(claims, decodedVouchToken.iss, issuerKeyPair)
}
export async function createRevokeToken(args, issuer, issuerKeyPair) {
const requiredArgs = [
'sub',
'vch_sum',
'vch_iss',
'revokes'
];
requiredArgs.forEach(fieldname => {
if (typeof args[fieldname] != 'string') {
throw new Error('No ' + fieldname + ' provided for revoke token');
}
});
const claims = {
...args,
iss: issuer,
kind: 'vch:revoke',
jti: args.jti || crypto.randomUUID(),
iat: args.iat || Math.floor(Date.now() / 1000),
};
if (claims.purpose) {
throw new Error('Revocation tokens must not include purpose');
}
if (claims.exp) {
throw new Error('Revocation tokens must not include exp');
}
if (!args.revokes || (args.revokes !== 'all' && !/^[0-9a-f\-]{36}$/.test(args.revokes))) {
throw new Error('revokes must be "all" or a valid UUID');
}
return createJwt(issuer, issuerKeyPair.publicKey, issuerKeyPair.privateKey, claims);
}
export async function createBurnToken(issuer, issuerKeyPair, args = {}) {
let jti = args.jti || crypto.randomUUID();
const iss_key = toBase64(issuerKeyPair.publicKey);
const claims = {
...args,
kind: 'vch:burn',
jti: jti,
sub: jti,
burns: issuer
};
return createJwt(issuer, iss_key, issuerKeyPair.privateKey, claims);
}
export async function validateVouchToken(token) {
const decoded = await verifyJwt(token);
if (!isValidKind(decoded.kind)) throw new Error('Invalid or missing `kind` claim');
if (!decoded.iss_key) throw new Error('Missing required iss_key in Vouchsafe token');
if (!decoded.jti || !isValidUUID(decoded.jti)) throw new Error('Missing or invalid jti');
if (!decoded.sub || typeof decoded.sub !== 'string') throw new Error('Missing or invalid sub');
if (!validateIssuerString(decoded.iss)) {
throw new Error('Invalid token: Iss does not contain a valid Vouchsafe ID');
}
const urnOk = await verifyUrnMatchesKey(decoded.iss, decoded.iss_key);
if (!urnOk) throw new Error('iss_key does not match URN in iss');
if (decoded.kind == 'vch:attest') {
// must be an attestation
if (decoded.sub != decoded.jti) {
throw new Error('Vouch tokens may not vouch for a token from the same issuer unless they are attestations');
}
if (typeof decoded.vch_iss != 'undefined') {
throw new Error('Attestations may not have a vch_iss');
}
if (typeof decoded.vch_sum != 'undefined') {
throw new Error('Attestations may not have a vch_sum');
}
if (typeof decoded.revokes != 'undefined') {
throw new Error('Attestations may not have revokes');
}
if (typeof decoded.burns != 'undefined') {
throw new Error('Attestations may not have burns');
}
} else if (decoded.kind == 'vch:vouch') {
// burn token. Let's check the rules.
if (typeof decoded.vch_iss == 'undefined') {
throw new Error('Vouch tokens must include vch_iss');
}
if (typeof decoded.vch_sum == 'undefined') {
throw new Error('Vouch tokens must have a vch_sum');
}
if (typeof decoded.revokes != 'undefined') {
throw new Error('Vouch tokens may not have revokes');
}
if (typeof decoded.purpose != 'undefined' && typeof decoded.purpose != 'string') {
throw new Error('Vouch token purpose must be a valid string ');
}
if (typeof decoded.purpose == 'string' && !/[a-z0-9\-_:\s]/.test(decoded.purpose)) {
throw new Error("Vouch token purpose may only contain the characters a-z, 0-9, '-', '_' and ':'");
}
} else if (decoded.kind == 'vch:burn') {
// burn token. Let's check the rules.
if (decoded.sub != decoded.jti) {
throw new Error('Burn tokens must reference themselves (sub must equal jti)');
}
if (typeof decoded.vch_sum != 'undefined') {
throw new Error('Burn tokens may not have a vch_sum');
}
if (typeof decoded.revokes != 'undefined') {
throw new Error('Burn tokens may not have revokes');
}
// burns can only reference the signing issuer.
if (decoded.burns != decoded.iss) {
throw new Error('Burn tokens may not reference a different issuer');
}
} else if (decoded.kind == 'vch:revoke') {
// revoke token.
if (typeof decoded.vch_iss != 'string') {
throw new Error('Missing vch_iss');
}
if (!validateIssuerString(decoded.vch_iss)) {
throw new Error('Invalid vch_iss');
}
// all other token types must have a vch_sum
if (!decoded.vch_sum || !/^[A-Za-z0-9+/=]+(\.sha256|\.sha512)?$/.test(decoded.vch_sum)) {
throw new Error('Invalid or missing vch_sum');
}
// if revokes is present, it's a revoke token.
// revoke tokens can't have a purpose claim
if (typeof decoded.purpose != 'undefined') throw new Error('Vouch token may not have both revokes and purpose');
if (decoded.revokes !== 'all' && !isValidUUID(decoded.revokes)) {
throw new Error('revokes field must be "all" or a UUID');
}
} else {
throw new Error('Invalid token: Unable to determine token type');
}
return decoded;
}
export async function verifyVouchToken(vouchJwt, subjectJwt) {
const vouchPayload = await validateVouchToken(vouchJwt);
const subjectPayload =await validateVouchToken(subjectJwt);
if (vouchPayload.sub !== subjectPayload.jti) {
throw new Error(`Vouch token 'sub' (${vouchPayload.sub}) does not match subject token 'jti' (${subjectPayload.jti})`);
}
if (vouchPayload.vch_iss !== subjectPayload.iss) {
throw new Error(`Vouch token 'vch_iss' (${vouchPayload.vch_iss}) does not match subject token 'iss' (${subjectPayload.iss})`);
}
// need to see if we are getting a different hash algorithm
let [expectedHash, providedAlgorithm] = vouchPayload.vch_sum.split('.');
let alg = 'sha256';
let digest = await hashJwt(subjectJwt, alg);
// Compare the hash: it may or may not have a suffix, so handle that
if (alg === 'sha256' && digest != expectedHash.replace(/\.sha256$/)) {
throw new Error('vch_sum does not match actual hash of subject token');
// otherwise the hashes must match exactly.
} else if (digest != expectedHash) {
throw new Error('vch_sum does not match actual hash of subject token');
}
return {
valid: true,
vouchPayload,
subjectPayload
};
}
export function isBurnToken(token) {
if (token.kind == 'vch:burn') {
if (token.burns == token.iss) {
return true;
} else {
throw new Error('Invalid Burn token, burns is defined but does not match issuer');
}
} else {
return false;
}
}
export function isRevocationToken(token) {
if (token.kind == 'vch:revoke') {
if (typeof token.revokes == 'string') {
return true;
} else {
throw new Error('Invalid Revoke token, no valid revoke target');
}
} else {
return false;
}
}