#!/usr/bin/env node /** * UEPS Consent Token — wire-level consent for LetherNet traffic * * A consent token is an Ed25519-signed message that authorises a specific * data flow between two parties for a limited time. Every hop in LetherNet * checks the consent token before forwarding. * * Token format: * { * version: 1, * issuer: "iTHN...", // wallet address of consent giver * subject: "iTHN...", // wallet address of consent recipient * level: 2, // consent level (0-4) * capabilities: ["vpn","dns"], // what the subject can do * issued: 1712000000, // unix timestamp * expires: 1712003600, // unix timestamp (1 hour default) * scope: "charon.lthn", // gateway scope * nonce: "random-hex", // replay protection * signature: "ed25519-sig" // signed by issuer's chain key * } * * Consent levels: * 0 = None (reject all) * 1 = Minimal (routing only, no data retention) * 2 = Standard (service provision, ephemeral data) * 3 = Extended (analytics, scoped retention) * 4 = Full (complete trust, persistent data) */ const crypto = require('crypto'); class ConsentToken { /** * Create a new consent token * @param {Object} opts * @param {string} opts.issuer - iTHN address of consent giver * @param {string} opts.subject - iTHN address of consent recipient * @param {number} opts.level - Consent level (0-4) * @param {string[]} opts.capabilities - Allowed capabilities * @param {string} opts.scope - Gateway scope (e.g., "charon.lthn") * @param {number} opts.ttl - Time to live in seconds (default: 3600) */ constructor(opts) { this.version = 1; this.issuer = opts.issuer; this.subject = opts.subject; this.level = Math.max(0, Math.min(4, opts.level || 0)); this.capabilities = opts.capabilities || []; this.scope = opts.scope || ''; this.issued = Math.floor(Date.now() / 1000); this.expires = this.issued + (opts.ttl || 3600); this.nonce = crypto.randomBytes(16).toString('hex'); this.signature = null; } /** * Get the signable payload (everything except signature) */ payload() { return JSON.stringify({ v: this.version, iss: this.issuer, sub: this.subject, lvl: this.level, cap: this.capabilities, scp: this.scope, iat: this.issued, exp: this.expires, non: this.nonce, }); } /** * Sign the token with an Ed25519 private key * In production, this uses the chain wallet's spend key */ sign(privateKey) { const payload = Buffer.from(this.payload()); this.signature = crypto.sign(null, payload, privateKey).toString('hex'); return this; } /** * Verify the token signature against a public key * In production, the public key comes from the chain alias lookup */ verify(publicKey) { if (!this.signature) return false; const payload = Buffer.from(this.payload()); const sig = Buffer.from(this.signature, 'hex'); return crypto.verify(null, payload, publicKey, sig); } /** * Check if token is currently valid (not expired, not future-dated) */ isValid() { const now = Math.floor(Date.now() / 1000); return now >= this.issued && now < this.expires; } /** * Check if token grants a specific capability */ hasCapability(cap) { return this.capabilities.includes(cap); } /** * Serialise to compact wire format */ toWire() { return Buffer.from(JSON.stringify({ ...JSON.parse(this.payload()), sig: this.signature, })).toString('base64'); } /** * Deserialise from wire format */ static fromWire(wire) { const data = JSON.parse(Buffer.from(wire, 'base64').toString()); const token = new ConsentToken({ issuer: data.iss, subject: data.sub, level: data.lvl, capabilities: data.cap, scope: data.scp, ttl: data.exp - data.iat, }); token.issued = data.iat; token.expires = data.exp; token.nonce = data.non; token.signature = data.sig; return token; } /** * Human-readable display */ toString() { const levelNames = ['None', 'Minimal', 'Standard', 'Extended', 'Full']; return `ConsentToken{${this.issuer?.slice(0,10)}→${this.subject?.slice(0,10)} L${this.level}(${levelNames[this.level]}) caps=[${this.capabilities}] scope=${this.scope} valid=${this.isValid()}}`; } } // === Test / Demo === if (require.main === module) { // Generate test keypair (in production, these come from the chain wallet) const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519'); // Create a consent token: user authorises gateway for VPN + DNS const token = new ConsentToken({ issuer: 'iTHNUNiuu3VP1yy8xH2y5iQaABKXurdjqZmzFiBiyR4dKG3j6534e9jMriY6SM7PH8NibVwVWW1DWJfQEWnSjS8n3Wgx86pQpY', subject: 'iTHNQGNCbu8BH7FhQuzsv5CVsE3veDX1CFCxk9jsqRfA4tFcUPNwQa4DWLFvyaKvs3HGb8xfGDGQq871c9P4iRpt8SFusoqSzJ', level: 2, capabilities: ['vpn', 'dns'], scope: 'charon.lthn', ttl: 3600, }); // Sign it token.sign(privateKey); console.log('Created:', token.toString()); console.log(''); // Verify it const valid = token.verify(publicKey); console.log('Signature valid:', valid); console.log('Token valid (not expired):', token.isValid()); console.log('Has VPN capability:', token.hasCapability('vpn')); console.log('Has compute capability:', token.hasCapability('compute')); console.log(''); // Wire format roundtrip const wire = token.toWire(); console.log('Wire format:', wire.slice(0, 60) + '...'); console.log('Wire size:', wire.length, 'bytes'); const restored = ConsentToken.fromWire(wire); console.log('Roundtrip:', restored.toString()); console.log('Roundtrip verify:', restored.verify(publicKey)); } module.exports = { ConsentToken };