188 lines
5.8 KiB
JavaScript
188 lines
5.8 KiB
JavaScript
|
|
#!/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 };
|