From 38087671de04f4dfab06ba317d14a1283e33430f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 22:42:11 +0100 Subject: [PATCH] feat(ueps): initial push Co-Authored-By: Claude Opus 4.6 (1M context) --- consent.js | 187 +++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 9 +++ 2 files changed, 196 insertions(+) create mode 100644 consent.js create mode 100644 package.json diff --git a/consent.js b/consent.js new file mode 100644 index 0000000..89e6ba2 --- /dev/null +++ b/consent.js @@ -0,0 +1,187 @@ +#!/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 }; diff --git a/package.json b/package.json new file mode 100644 index 0000000..7a5ae66 --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "name": "lthn-ueps", + "version": "0.1.0", + "description": "UEPS consent tokens for LetherNet wire-level consent", + "main": "consent.js", + "scripts": { + "test": "node consent.js" + } +}