diff --git a/Auth.js b/Auth.js new file mode 100644 index 0000000..8ba0357 --- /dev/null +++ b/Auth.js @@ -0,0 +1,518 @@ +import { EventBase } from './EventBase.js'; + +var text_encoder = new TextEncoder(); +var text_decoder = new TextDecoder(); + +function ensure_crypto() { + if (globalThis.crypto == undefined || globalThis.crypto.subtle == undefined) { + throw new Error("Web Crypto support is required for TaiiNet authentication"); + } + return globalThis.crypto.subtle; +} + +function is_plain_object(value) { + return value != null && typeof (value) == "object" && Array.isArray(value) == false; +} + +function sort_value(value) { + if (Array.isArray(value)) { + return value.map(sort_value); + } + if (is_plain_object(value)) { + var sorted = {}; + Object.keys(value).sort().forEach(function (key) { + sorted[key] = sort_value(value[key]); + }); + return sorted; + } + return value; +} + +function stable_stringify(value) { + return JSON.stringify(sort_value(value)); +} + +function array_buffer_to_base64url(buffer) { + var bytes = new Uint8Array(buffer); + var binary = ""; + for (var i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + if (typeof (btoa) == "function") { + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); + } + return Buffer.from(binary, "binary").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} + +function base64url_to_uint8array(value) { + var padded = value.replace(/-/g, "+").replace(/_/g, "/"); + while (padded.length % 4 != 0) { + padded += "="; + } + var binary; + if (typeof (atob) == "function") { + binary = atob(padded); + } + else { + binary = Buffer.from(padded, "base64").toString("binary"); + } + var bytes = new Uint8Array(binary.length); + for (var i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +function utf8_bytes(value) { + return text_encoder.encode(value); +} + +function clone_json(value) { + return JSON.parse(JSON.stringify(value)); +} + +async function generate_signing_keys() { + return ensure_crypto().generateKey({ + name: "ECDSA", + namedCurve: "P-256" + }, true, ["sign", "verify"]); +} + +async function generate_encryption_keys() { + return ensure_crypto().generateKey({ + name: "ECDH", + namedCurve: "P-256" + }, true, ["deriveBits"]); +} + +async function export_public_key(key) { + return array_buffer_to_base64url(await ensure_crypto().exportKey("spki", key)); +} + +async function export_private_key(key) { + return array_buffer_to_base64url(await ensure_crypto().exportKey("pkcs8", key)); +} + +async function import_signing_public_key(serialized_key) { + return ensure_crypto().importKey("spki", base64url_to_uint8array(serialized_key), { + name: "ECDSA", + namedCurve: "P-256" + }, true, ["verify"]); +} + +async function import_signing_private_key(serialized_key) { + return ensure_crypto().importKey("pkcs8", base64url_to_uint8array(serialized_key), { + name: "ECDSA", + namedCurve: "P-256" + }, true, ["sign"]); +} + +async function import_encryption_public_key(serialized_key) { + return ensure_crypto().importKey("spki", base64url_to_uint8array(serialized_key), { + name: "ECDH", + namedCurve: "P-256" + }, true, []); +} + +async function import_encryption_private_key(serialized_key) { + return ensure_crypto().importKey("pkcs8", base64url_to_uint8array(serialized_key), { + name: "ECDH", + namedCurve: "P-256" + }, true, ["deriveBits"]); +} + +async function export_key_pair(key_pair) { + return { + publicKey: await export_public_key(key_pair.publicKey), + privateKey: await export_private_key(key_pair.privateKey) + }; +} + +async function serialize_key_material(signing_keys, encryption_keys) { + return { + signing: await export_key_pair(signing_keys), + encryption: await export_key_pair(encryption_keys) + }; +} + +async function import_key_material(serialized_keys) { + return { + signing: { + publicKey: await import_signing_public_key(serialized_keys.signing.publicKey), + privateKey: await import_signing_private_key(serialized_keys.signing.privateKey) + }, + encryption: { + publicKey: await import_encryption_public_key(serialized_keys.encryption.publicKey), + privateKey: await import_encryption_private_key(serialized_keys.encryption.privateKey) + } + }; +} + +async function sign_value(private_key, value) { + var payload = utf8_bytes(stable_stringify(value)); + var signature = await ensure_crypto().sign({ + name: "ECDSA", + hash: "SHA-256" + }, private_key, payload); + return array_buffer_to_base64url(signature); +} + +async function verify_value(public_key, value, signature) { + return ensure_crypto().verify({ + name: "ECDSA", + hash: "SHA-256" + }, public_key, base64url_to_uint8array(signature), utf8_bytes(stable_stringify(value))); +} + +async function derive_shared_key(private_key, public_key) { + var shared_secret = await ensure_crypto().deriveBits({ + name: "ECDH", + public: public_key + }, private_key, 256); + return ensure_crypto().importKey("raw", shared_secret, { + name: "AES-GCM" + }, false, ["encrypt", "decrypt"]); +} + +function random_iv() { + return globalThis.crypto.getRandomValues(new Uint8Array(12)); +} + +async function encrypt_bytes(key, value) { + var iv = random_iv(); + var encrypted = await ensure_crypto().encrypt({ + name: "AES-GCM", + iv: iv + }, key, value); + return { + iv: array_buffer_to_base64url(iv), + data: array_buffer_to_base64url(encrypted) + }; +} + +async function decrypt_bytes(key, payload) { + return new Uint8Array(await ensure_crypto().decrypt({ + name: "AES-GCM", + iv: base64url_to_uint8array(payload.iv) + }, key, base64url_to_uint8array(payload.data))); +} + +async function create_content_key() { + var value = globalThis.crypto.getRandomValues(new Uint8Array(32)); + return { + bytes: value, + cryptoKey: await ensure_crypto().importKey("raw", value, { + name: "AES-GCM" + }, false, ["encrypt", "decrypt"]) + }; +} + +async function encrypt_payload(payload, sender_keys, recipient_public_keys) { + var content_key = await create_content_key(); + var encrypted_payload = await encrypt_bytes(content_key.cryptoKey, utf8_bytes(JSON.stringify(payload))); + var wrapped_keys = []; + + for (var i = 0; i < recipient_public_keys.length; i++) { + var recipient = recipient_public_keys[i]; + var public_key = recipient.publicKey || recipient; + var imported_public_key = await import_encryption_public_key(public_key); + var wrapping_key = await derive_shared_key(sender_keys.privateKey, imported_public_key); + wrapped_keys.push({ + publicKey: public_key, + wrappedKey: await encrypt_bytes(wrapping_key, content_key.bytes) + }); + } + + return { + senderPublicKey: await export_public_key(sender_keys.publicKey), + recipients: wrapped_keys, + payload: encrypted_payload + }; +} + +async function decrypt_payload(payload, recipient_keys) { + var recipient_public_key = await export_public_key(recipient_keys.publicKey); + var wrapped_key = null; + for (var i = 0; i < payload.recipients.length; i++) { + if (payload.recipients[i].publicKey == recipient_public_key) { + wrapped_key = payload.recipients[i].wrappedKey; + break; + } + } + if (wrapped_key == null) { + throw new Error("Message was not encrypted for this device"); + } + + var sender_public_key = await import_encryption_public_key(payload.senderPublicKey); + var wrapping_key = await derive_shared_key(recipient_keys.privateKey, sender_public_key); + var content_key_bytes = await decrypt_bytes(wrapping_key, wrapped_key); + var content_key = await ensure_crypto().importKey("raw", content_key_bytes, { + name: "AES-GCM" + }, false, ["decrypt"]); + var decrypted_payload = await decrypt_bytes(content_key, payload.payload); + return JSON.parse(text_decoder.decode(decrypted_payload)); +} + +function build_state(auth) { + return { + username: auth.username, + identity: auth.identity == null ? null : clone_json(auth.identity), + device: auth.device == null ? null : clone_json(auth.device), + registry: clone_json(auth.registry) + }; +} + +export function is_auth_envelope(value) { + return is_plain_object(value) && is_plain_object(value.auth) && value.auth.version == 1; +} + +export class TaiiNetAuth extends EventBase { + constructor() { + super(); + this.registry = { + usernames: {}, + publicKeys: {} + }; + this.username = null; + this.identity = null; + this.device = null; + } + + emit_change() { + this.trigger("change", this.getState()); + } + + getState() { + return build_state(this); + } + + registerUsername(username, registration) { + var record = clone_json(registration); + record.username = username; + this.registry.usernames[username] = record; + for (var i in record.publicKeys) { + this.registry.publicKeys[record.publicKeys[i]] = username; + } + this.emit_change(); + return clone_json(record); + } + + lookupUsernameByPublicKey(public_key) { + return this.registry.publicKeys[public_key] || null; + } + + lookupPublicKeysByUsername(username) { + if (this.registry.usernames[username] == undefined) { + return null; + } + return clone_json(this.registry.usernames[username].publicKeys); + } + + async createIdentity(username) { + var signing_keys = await generate_signing_keys(); + var encryption_keys = await generate_encryption_keys(); + var serialized_keys = await serialize_key_material(signing_keys, encryption_keys); + + this.username = username; + this.identity = { + username: username, + publicKeys: { + signing: serialized_keys.signing.publicKey, + encryption: serialized_keys.encryption.publicKey + } + }; + this.device = { + name: "primary", + ownerPublicKey: serialized_keys.signing.publicKey, + publicKeys: { + signing: serialized_keys.signing.publicKey, + encryption: serialized_keys.encryption.publicKey + }, + keys: { + signing: signing_keys, + encryption: encryption_keys + } + }; + this.registerUsername(username, { + publicKeys: { + owner: serialized_keys.signing.publicKey, + ownerEncryption: serialized_keys.encryption.publicKey, + primary: serialized_keys.signing.publicKey, + primaryEncryption: serialized_keys.encryption.publicKey + } + }); + this.emit_change(); + return this.getState(); + } + + async createDeviceToken(options) { + if (this.identity == null || this.device == null) { + throw new Error("Create an identity before generating device tokens"); + } + var device_name = options && options.name ? options.name : "device"; + var expires_in_ms = options && options.expiresInMs ? options.expiresInMs : 5 * 60 * 1000; + var device_signing_keys = await generate_signing_keys(); + var device_encryption_keys = await generate_encryption_keys(); + var serialized_keys = await serialize_key_material(device_signing_keys, device_encryption_keys); + var issued_at = new Date().toISOString(); + var expires_at = new Date(Date.now() + expires_in_ms).toISOString(); + var payload = { + version: 1, + username: this.username, + ownerPublicKey: this.identity.publicKeys.signing, + ownerEncryptionPublicKey: this.identity.publicKeys.encryption, + deviceName: device_name, + devicePublicKeys: { + signing: serialized_keys.signing.publicKey, + encryption: serialized_keys.encryption.publicKey + }, + issuedAt: issued_at, + expiresAt: expires_at + }; + var signature = await sign_value(this.device.keys.signing.privateKey, payload); + return array_buffer_to_base64url(utf8_bytes(JSON.stringify({ + payload: payload, + signature: signature, + privateKeys: { + signing: serialized_keys.signing.privateKey, + encryption: serialized_keys.encryption.privateKey + } + }))); + } + + async importDeviceToken(token) { + var bundle = JSON.parse(text_decoder.decode(base64url_to_uint8array(token))); + if (Date.parse(bundle.payload.expiresAt) < Date.now()) { + throw new Error("Authentication token has expired"); + } + var owner_public_key = await import_signing_public_key(bundle.payload.ownerPublicKey); + if (!await verify_value(owner_public_key, bundle.payload, bundle.signature)) { + throw new Error("Authentication token signature is invalid"); + } + + var serialized_keys = { + signing: { + publicKey: bundle.payload.devicePublicKeys.signing, + privateKey: bundle.privateKeys.signing + }, + encryption: { + publicKey: bundle.payload.devicePublicKeys.encryption, + privateKey: bundle.privateKeys.encryption + } + }; + var imported_keys = await import_key_material(serialized_keys); + if (await export_public_key(imported_keys.signing.publicKey) != bundle.payload.devicePublicKeys.signing) { + throw new Error("Authentication token signing key does not match the public key"); + } + if (await export_public_key(imported_keys.encryption.publicKey) != bundle.payload.devicePublicKeys.encryption) { + throw new Error("Authentication token encryption key does not match the public key"); + } + + this.username = bundle.payload.username; + this.identity = { + username: bundle.payload.username, + publicKeys: { + signing: bundle.payload.ownerPublicKey, + encryption: bundle.payload.ownerEncryptionPublicKey + } + }; + this.device = { + name: bundle.payload.deviceName, + ownerPublicKey: bundle.payload.ownerPublicKey, + publicKeys: clone_json(bundle.payload.devicePublicKeys), + keys: imported_keys + }; + this.registerUsername(bundle.payload.username, { + publicKeys: { + owner: bundle.payload.ownerPublicKey, + ownerEncryption: bundle.payload.ownerEncryptionPublicKey, + device: bundle.payload.devicePublicKeys.signing, + deviceEncryption: bundle.payload.devicePublicKeys.encryption + } + }); + this.emit_change(); + return this.getState(); + } + + async sealMessage(payload, options) { + if (this.device == null) { + throw new Error("Create or import an identity before sealing messages"); + } + var normalized_options = options || {}; + var should_sign = normalized_options.sign !== false; + var recipients = normalized_options.encryptFor || normalized_options.recipientPublicKeys || []; + var encrypted = recipients.length > 0; + var auth = { + version: 1, + username: this.username, + signed: should_sign, + encrypted: encrypted, + publicKeys: { + owner: this.identity.publicKeys.signing, + ownerEncryption: this.identity.publicKeys.encryption, + device: this.device.publicKeys.signing, + deviceEncryption: this.device.publicKeys.encryption + }, + createdAt: new Date().toISOString() + }; + var sealed_payload = payload; + if (encrypted) { + sealed_payload = await encrypt_payload(payload, this.device.keys.encryption, recipients); + auth.recipientPublicKeys = sealed_payload.recipients.map(function (recipient) { + return recipient.publicKey; + }); + } + var signature_payload = { + auth: auth, + payload: sealed_payload + }; + return { + auth: auth, + payload: sealed_payload, + signature: should_sign ? await sign_value(this.device.keys.signing.privateKey, signature_payload) : null + }; + } + + async openMessage(message) { + if (!is_auth_envelope(message)) { + return { + authenticated: false, + verified: false, + encrypted: false, + data: message, + envelope: message, + username: null + }; + } + + var verified = false; + if (message.auth.signed && message.signature != null) { + var signing_public_key = await import_signing_public_key(message.auth.publicKeys.device); + verified = await verify_value(signing_public_key, { + auth: message.auth, + payload: message.payload + }, message.signature); + if (!verified) { + throw new Error("Authenticated message signature is invalid"); + } + } + + var opened_payload = message.payload; + if (message.auth.encrypted) { + if (this.device == null) { + throw new Error("Cannot decrypt an authenticated message without a device key"); + } + opened_payload = await decrypt_payload(message.payload, this.device.keys.encryption); + } + + return { + authenticated: true, + verified: verified, + encrypted: message.auth.encrypted, + data: opened_payload, + envelope: message, + username: this.lookupUsernameByPublicKey(message.auth.publicKeys.owner) || message.auth.username || null + }; + } +} + diff --git a/EventBase.js b/EventBase.js index 7c05097..d2f7187 100644 --- a/EventBase.js +++ b/EventBase.js @@ -11,7 +11,19 @@ export class EventBase { this.callbacks[event].push(callback); } + off(event, callback) { + if (this.callbacks[event] == undefined) { + return; + } + this.callbacks[event] = this.callbacks[event].filter(function (handler) { + return handler != callback; + }); + } + trigger(event, ...data) { + if (this.callbacks[event] == undefined) { + return; + } for (var i in this.callbacks[event]) { this.callbacks[event][i](...data); } diff --git a/README.md b/README.md index 248e54d..16cbe9a 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ on around the network to everyone who's interested. ![mesh vs trad](https://tucu.ca/wp-content/uploads/2014/02/traditional-WiFI-vs-mesh-WiFI-network.png) ## Simple to Use + TaiiNet can be used just like a database, only it scales automatically. ```javascript @@ -58,6 +59,86 @@ sub.send({ }); ``` +## Authentication + +TaiiNet now includes an optional authentication helper in +`./Auth.js` for signing messages, encrypting them +for one or more recipients, registering usernames against public keys, and +issuing short-lived device transfer tokens. + +```javascript +import { TaiiNet } from './TaiiNet.js'; +import { TaiiNetAuth } from './Auth.js'; + +var auth = new TaiiNetAuth(); +await auth.createIdentity("alice"); + +var taiinet = new TaiiNet({ auth: auth }); +var sub = taiinet.new(taiinet.Subscription, { + "auth.publicKeys.owner": auth.getState().identity.publicKeys.signing +}); + +sub.on("data", function (message, event, auth_message) { + console.log(message, auth_message.verified); +}); + +await sub.sendSecure({ + type: "chat", + text: "hello world" +}, { + sign: true +}); +``` + +### Encryption + +Pass recipient encryption public keys to `sendSecure` or `auth.sealMessage`. +Encrypted envelopes still expose `auth.publicKeys.*`, `auth.username`, +`auth.recipientPublicKeys`, and `auth.createdAt`, so subscribers can filter by +public key before decryption. + +```javascript +var recipient = bobAuth.getState().identity.publicKeys.encryption; +await sub.sendSecure({ text: "secret" }, { + encryptFor: [recipient] +}); +``` + +### Username Registration and Lookup + +The authentication manager keeps an in-memory public-key registry: + +```javascript +auth.lookupUsernameByPublicKey(publicKey); +auth.lookupPublicKeysByUsername("alice"); +auth.registerUsername("alice", { + publicKeys: { + owner: ownerSigningPublicKey, + ownerEncryption: ownerEncryptionPublicKey + } +}); +``` + +### Device Transfer Tokens / QR Payloads + +Use a short-lived transfer token to move a signed device sub-key to another +device without passwords: + +```javascript +var token = await auth.createDeviceToken({ name: "phone" }); +await otherDeviceAuth.importDeviceToken(token); +``` + +The token is a compact base64url string, so it can be displayed directly or +embedded inside a QR code by the application UI. + +### React Hooks + +TaiiNet does not bundle React directly, but `./TaiiNetAuthReact.js` +exports `createTaiiNetAuthHooks(React, auth)`. Pass your React instance and a +`TaiiNetAuth` object to receive a `useTaiiNetAuth` hook with live auth state and +actions. + ## Video/Audio Streaming (Yes, like decentralized Twitch) > Note: this feature is not implemented at all, but I wanted to outline it diff --git a/Subscription.js b/Subscription.js index ea2c0dd..a226885 100644 --- a/Subscription.js +++ b/Subscription.js @@ -1,9 +1,13 @@ import { match_queries, query_match_data } from './TaiiNet.js'; import { EventBase } from "./EventBase.js"; +import { is_auth_envelope } from "./Auth.js"; export class Subscription extends EventBase { - constructor(sn, swarm, query, backlog) { + constructor(sn, swarm, query, options) { super(); + var normalized_options = options != null && typeof (options) == "object" && Array.isArray(options) == false ? options : { + backlog: options + }; this.query = query; this.maximum_upstream_peers = 3; this.upstream_peers = []; @@ -13,7 +17,8 @@ export class Subscription extends EventBase { this.swarm = swarm; this.connection_pool = []; this.messages = []; - this.backlog = backlog; + this.backlog = normalized_options.backlog; + this.auth = normalized_options.auth || this.sn.auth || null; // add all the peers from the swarm that have all the data we need for this // subscription to our list of peers @@ -89,7 +94,18 @@ export class Subscription extends EventBase { } // handles what the subscription does on receipt of data - handle_data(data, e) { + async handle_data(data, e) { + if (this.auth != null && is_auth_envelope(data.data)) { + try { + var opened_message = await this.auth.openMessage(data.data); + this.trigger("auth-data", opened_message, e); + this.trigger("data", opened_message.data, e, opened_message); + } + catch (error) { + this.trigger("auth-error", error, data.data, e); + } + return; + } this.trigger("data", data.data, e); } @@ -111,4 +127,13 @@ export class Subscription extends EventBase { send(data) { this.swarm.send(data); } + + async sendSecure(data, options) { + if (this.auth == null) { + throw new Error("Subscription has no authentication manager configured"); + } + var sealed_message = await this.auth.sealMessage(data, options); + this.swarm.send(sealed_message); + return sealed_message; + } } diff --git a/TaiiNet.js b/TaiiNet.js index bfa7b83..ea87266 100644 --- a/TaiiNet.js +++ b/TaiiNet.js @@ -1,6 +1,7 @@ import { Swarm } from './Swarm.js'; import { EventBase } from './EventBase.js'; import { Subscription } from './Subscription.js'; +import { TaiiNetAuth } from './Auth.js'; var signallers = [ //"ws://167.160.189.251:5000/api/1", @@ -9,10 +10,13 @@ var signallers = [ // represents a connection to the signal server export class TaiiNet extends EventBase { - constructor() { + constructor(options) { super(); + var normalized_options = options || {}; // pass the default subscription types through for convenience this.Subscription = Subscription; + this.Auth = TaiiNetAuth; + this.auth = normalized_options.auth || null; // connect to a random signaller this.get_signaller(); @@ -40,6 +44,20 @@ export class TaiiNet extends EventBase { return new type(this, this.swarm, query, options); } + async authenticate(data, options) { + if (this.auth == null) { + throw new Error("TaiiNet has no authentication manager configured"); + } + return this.auth.sealMessage(data, options); + } + + async open_authenticated_message(message) { + if (this.auth == null) { + throw new Error("TaiiNet has no authentication manager configured"); + } + return this.auth.openMessage(message); + } + get_signaller(callbacks) { // select random signaller var host = signallers[Math.floor(Math.random() * signallers.length)]; diff --git a/TaiiNetAuthReact.js b/TaiiNetAuthReact.js new file mode 100644 index 0000000..4c56ccd --- /dev/null +++ b/TaiiNetAuthReact.js @@ -0,0 +1,42 @@ +export function createTaiiNetAuthHooks(React, auth) { + if (React == undefined || typeof (React.useSyncExternalStore) != "function" || typeof (React.useMemo) != "function") { + throw new Error("createTaiiNetAuthHooks requires React.useSyncExternalStore and React.useMemo"); + } + + var subscribe = function (on_store_change) { + auth.on("change", on_store_change); + return function () { + auth.off("change", on_store_change); + }; + }; + + var get_snapshot = function () { + return auth.getState(); + }; + + function useTaiiNetAuth() { + var state = React.useSyncExternalStore(subscribe, get_snapshot, get_snapshot); + return React.useMemo(function () { + return { + state: state, + username: state.username, + identity: state.identity, + device: state.device, + registry: state.registry, + createIdentity: auth.createIdentity.bind(auth), + registerUsername: auth.registerUsername.bind(auth), + lookupUsernameByPublicKey: auth.lookupUsernameByPublicKey.bind(auth), + lookupPublicKeysByUsername: auth.lookupPublicKeysByUsername.bind(auth), + createDeviceToken: auth.createDeviceToken.bind(auth), + importDeviceToken: auth.importDeviceToken.bind(auth), + sealMessage: auth.sealMessage.bind(auth), + openMessage: auth.openMessage.bind(auth) + }; + }, [state]); + } + + return { + useTaiiNetAuth: useTaiiNetAuth + }; +} + diff --git a/tests/auth.test.js b/tests/auth.test.js new file mode 100644 index 0000000..c7abc85 --- /dev/null +++ b/tests/auth.test.js @@ -0,0 +1,215 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +function get_path(object, path) { + var parts = path.split("."); + var current = object; + for (var i = 0; i < parts.length; i++) { + if (current == undefined) { + return undefined; + } + current = current[parts[i]]; + } + return current; +} + +function matches(query, data) { + for (var key in query) { + var expected = query[key]; + var actual = key.indexOf(".") >= 0 ? get_path(data, key) : data[key]; + if (expected != null && typeof (expected) == "object" && Array.isArray(expected) == false) { + if (expected.$eq != undefined && actual != expected.$eq) { + return false; + } + if (expected.$ne != undefined && actual == expected.$ne) { + return false; + } + if (expected.$gt != undefined && !(actual > expected.$gt)) { + return false; + } + if (expected.$lt != undefined && !(actual < expected.$lt)) { + return false; + } + continue; + } + if (actual != expected) { + return false; + } + } + return true; +} + +globalThis.mingo = { + Query: class { + constructor(query) { + this.query = query; + } + + test(data) { + return matches(this.query, data); + } + } +}; + +const { TaiiNetAuth } = await import('/tmp/workspace/Taiiwo/TaiiNet/Auth.js'); +const { createTaiiNetAuthHooks } = await import('/tmp/workspace/Taiiwo/TaiiNet/TaiiNetAuthReact.js'); +const { query_match_data } = await import('/tmp/workspace/Taiiwo/TaiiNet/TaiiNet.js'); +const { Subscription } = await import('/tmp/workspace/Taiiwo/TaiiNet/Subscription.js'); + +function create_stub_signaller() { + return { + emit() { }, + on() { } + }; +} + +function create_stub_swarm() { + return { + all_peers: {}, + on() { }, + send_calls: [], + send(data) { + this.send_calls.push(data); + } + }; +} + +test('createIdentity registers username lookups', async function () { + var auth = new TaiiNetAuth(); + var state = await auth.createIdentity("alice"); + + assert.equal(state.username, "alice"); + assert.equal(auth.lookupUsernameByPublicKey(state.identity.publicKeys.signing), "alice"); + assert.deepEqual(auth.lookupPublicKeysByUsername("alice"), { + owner: state.identity.publicKeys.signing, + ownerEncryption: state.identity.publicKeys.encryption, + primary: state.device.publicKeys.signing, + primaryEncryption: state.device.publicKeys.encryption + }); +}); + +test('sealMessage and openMessage preserve signed payloads', async function () { + var auth = new TaiiNetAuth(); + await auth.createIdentity("alice"); + + var envelope = await auth.sealMessage({ text: "hello" }, { sign: true }); + var opened = await auth.openMessage(envelope); + + assert.equal(opened.authenticated, true); + assert.equal(opened.verified, true); + assert.deepEqual(opened.data, { text: "hello" }); + assert.equal(opened.username, "alice"); +}); + +test('encrypted messages can be filtered by public key and decrypted by recipients', async function () { + var alice = new TaiiNetAuth(); + var bob = new TaiiNetAuth(); + await alice.createIdentity("alice"); + await bob.createIdentity("bob"); + + var bob_public_keys = bob.getState().identity.publicKeys; + var envelope = await alice.sealMessage({ text: "secret" }, { + encryptFor: [bob_public_keys.encryption] + }); + var alice_public_key = alice.getState().identity.publicKeys.signing; + + assert.equal(query_match_data({ + "auth.publicKeys.owner": alice_public_key + }, envelope), true); + + var opened = await bob.openMessage(envelope); + assert.equal(opened.encrypted, true); + assert.deepEqual(opened.data, { text: "secret" }); +}); + +test('device transfer tokens import signed sub keys on another device', async function () { + var primary = new TaiiNetAuth(); + await primary.createIdentity("alice"); + + var token = await primary.createDeviceToken({ name: "phone" }); + var secondary = new TaiiNetAuth(); + var state = await secondary.importDeviceToken(token); + + assert.equal(state.username, "alice"); + assert.notEqual(state.device.publicKeys.signing, state.identity.publicKeys.signing); + assert.equal(secondary.lookupUsernameByPublicKey(state.device.publicKeys.signing), "alice"); +}); + +test('tampered device tokens are rejected', async function () { + var primary = new TaiiNetAuth(); + await primary.createIdentity("alice"); + var token = await primary.createDeviceToken({ name: "tablet" }); + var tampered = JSON.parse(Buffer.from(token.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")); + tampered.payload.deviceName = "attacker"; + var tampered_token = Buffer.from(JSON.stringify(tampered), "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); + + var secondary = new TaiiNetAuth(); + await assert.rejects(function () { + return secondary.importDeviceToken(tampered_token); + }, /invalid/); +}); + +test('Subscription.sendSecure wraps outgoing payloads in auth envelopes', async function () { + var auth = new TaiiNetAuth(); + await auth.createIdentity("alice"); + var swarm = create_stub_swarm(); + var subscription = new Subscription({ + auth: auth, + signaller: create_stub_signaller() + }, swarm, { room: "general" }, {}); + + var envelope = await subscription.sendSecure({ text: "hello" }, { sign: true }); + + assert.equal(swarm.send_calls.length, 1); + assert.deepEqual(swarm.send_calls[0], envelope); + assert.equal(envelope.auth.publicKeys.owner, auth.getState().identity.publicKeys.signing); +}); + +test('Subscription.handle_data emits decrypted auth payloads', async function () { + var alice = new TaiiNetAuth(); + var bob = new TaiiNetAuth(); + await alice.createIdentity("alice"); + await bob.createIdentity("bob"); + var subscription = new Subscription({ + auth: bob, + signaller: create_stub_signaller() + }, create_stub_swarm(), {}, {}); + var envelope = await alice.sealMessage({ text: "for bob" }, { + encryptFor: [bob.getState().identity.publicKeys.encryption] + }); + var received_payload = null; + var received_auth = null; + subscription.on("data", function (data, e, auth_message) { + received_payload = data; + received_auth = auth_message; + }); + + await subscription.handle_data({ data: envelope }, {}); + + assert.deepEqual(received_payload, { text: "for bob" }); + assert.equal(received_auth.verified, true); + assert.equal(received_auth.username, "alice"); +}); + +test('createTaiiNetAuthHooks exposes auth state and actions', async function () { + var auth = new TaiiNetAuth(); + await auth.createIdentity("alice"); + var subscribers = []; + var React = { + useSyncExternalStore(subscribe, get_snapshot) { + subscribers.push(subscribe); + return get_snapshot(); + }, + useMemo(factory) { + return factory(); + } + }; + + var hooks = createTaiiNetAuthHooks(React, auth); + var result = hooks.useTaiiNetAuth(); + + assert.equal(result.username, "alice"); + assert.equal(typeof (result.createDeviceToken), "function"); + assert.equal(typeof (subscribers[0](function () { })), "function"); +}); +