// The PasspetRemote XPCOM component communicates with a remote Passpet
// storage server.

// Shorthand for getting XPConnect interfaces, classes, objects, and services.
const XPI = Components.interfaces, XPC = Components.classes;
function XPC_(c) { return XPC[c.match(/^@/) ? c : '@mozilla.org/' + c]; }
function XPO(c, i) { return XPC_(c) ? XPC_(c).createInstance(i) : null; }
function XPS(c, i) { return XPC_(c) ? XPC_(c).getService(i) : null; }
function XQI(o, i) { return o.QueryInterface(i); }

const XPR = Components.results;
const CID = Components.ID('{23aab295-39f4-5fd9-97a3-2c6bee3d7d5b}');
const NAME = 'Passpet Remote Storage Client';
const CONTRACT = '@passpet.org/remote-storage;1';

// The module registers and unregisters the PasspetRemote component interface.
function NSGetModule(manager, filespec) {
    return new function() {
        this.registerSelf = function(manager, filespec, location, type) {
            manager = XQI(manager, XPI.nsIComponentRegistrar);
            manager.registerFactoryLocation(
                CID, NAME, CONTRACT, filespec, location, type);
        };

        this.unregisterSelf = function(manager, location, type) {
            manager = XQI(manager, XPI.nsIComponentRegistrar);
            manager.unregisterFactoryLocation(CID, location);
        };

        this.canUnload = function(manager) {
            return true;
        };

        this.getClassObject = function(manager, cid, iid) {
            if (!iid.equals(XPI.nsIFactory)) throw XPR.NS_ERROR_NOT_IMPLEMENTED;
            if (!cid.equals(CID)) throw XPR.NS_ERROR_NO_INTERFACE;
            return new function() {
                this.createInstance = function(outer, iid) {
                    if (outer != null) throw XPR.NS_ERROR_NO_AGGREGATION;
                    return XQI(new service(), iid);
                };
            };
        };
    };
};

const chr = String.fromCharCode;

const SRP = function() {
    const pj = XPS('@passpet.org/java;1', XPI.IPasspetJava);
    const java = pj.wrappedJSObject.java;
    const rng = new java.util.Random();
    const long = java.math.BigInteger;
    const zero = new long('0');
    const N = new long('125617018995153554710546479714086468244499594888726646874671447258204721048803');
    const g = new long('2');
    const hasher = XPO('@passpet.org/iterated-hash;1', XPI.IPasspetHash);
    const szr = XPO('@passpet.org/serializer;1', XPI.IPasspetSerializer);

    function value(str) {
        return new long(szr.toHex(str), 16);
    }

    function H() {
        var args = [];
        for (var i = 0; i < arguments.length; i++) args.push(arguments[i]);
        return value(hasher.hash(1, args.join('-'))).mod(N);
    }

    const k = H(N, g);
    
    function random() { return (new long(256, rng)).mod(N); }
    function exp(n) { return g.modPow(n, N); }
    function pow(x, y) { return x.modPow(y, N); }

    this.setup = function(password) {
        var s = random();
        var x = H(s, value(password));
        var v = exp(x);
        this.salt = s;
        this.verifier = v;
    };

    this.login = function(username, password, send) {
        var I = value(username);
        var p = value(password);
        var a, A, s, B, u, x, S, K, M;
        var step = 0;

        this.done = false;

        send(1); // authentication protocol version
        a = random();
        A = exp(a);
        send(A);

        this.proceed = function(input) {
            var n = input ? (new long(input)).mod(N) : zero;
            switch (step) {
                case 0:
                    s = n;
                    break;
                case 1:
                    B = n;
                    if (B.equals(zero)) throw 'B is zero';
                    u = H(A, B);
                    if (u.equals(zero)) throw 'u is zero';
                    x = H(s, p);
                    S = pow(B.subtract(k.multiply(exp(x))),
                            a.add(u.multiply(x)));
                    K = H(S);
                    M = H(H(N).xor(H(g)), H(I), s, A, B, K);
                    send(M);
                    break;
                case 2:
                    if (!n.equals(H(A, M, K))) throw 'H(A, M, K) mismatch';
                    this.key = '';
                    const base = new long(256);
                    for (var i = 0; i < 16; i++) {
                        this.key = chr(K.mod(base).intValue()) + this.key;
                        K = K.shiftRight(8);
                    }
                    this.done = true;
            }
            step++;
        };
    };
};

// ---------------------------------------------------- continuation helpers

function Cont(id, proceed, abort, error) {
    var pfunc = (proceed == null) ? (function() { }) :
                (proceed.proceed == null) ? proceed : proceed.proceed;
    this.proceed = function() {
        var args = '';
        for (var i = 0; i < arguments.length; i++) args += ', ' + arguments[i];
        dump('Cont ' + id + ' proceed: ' + args.substring(2) + '\n');
        pfunc.apply(null, arguments);
    }
    var afunc = (abort == null) ? (function() { }) :
                (abort.abort == null) ? abort : abort.abort;
    if (error == null) {
        this.abort = function(e) {
            dump('Cont ' + id + ' abort: ' + e + '\n');
            afunc(e);
        }
    } else {
        this.abort = function(e) {
            dump('Cont ' + id + ' abort: ' + e + ' -> ' + error + '\n');
            afunc(error);
        }
    }
    this.cancel = function() {
        this.proceed = function() { };
        this.abort = function() { };
    };
}

const NULL_CONTINUATION = new Cont();
NULL_CONTINUATION.cancel();

// ----------------------------- implementation of the PasspetRemote service

const service = function() {
    const sts = XPS('network/socket-transport-service;1',
                    XPI.nsISocketTransportService);
    const szr = XPO('@passpet.org/serializer;1', XPI.IPasspetSerializer);
    const srp = new SRP();

    // Failure codes passed to cont.abort().
    const SERVER_NOT_FOUND = this.SERVER_NOT_FOUND = 'server not found';
    const LOGIN_FAILED = this.LOGIN_FAILED = 'login failed';
    const WRITE_REFUSED = this.WRITE_REFUSED = 'write refused';
    const INVALID_REQUEST = this.INVALID_REQUEST = 'invalid request';
    const SERVER_ERROR = this.SERVER_ERROR = 'server error';
    const CONNECTION_ERROR = this.CONNECTION_ERROR = 'connection error';

    var callid = 0;

    function connect(address, cont) {
        var cid = ++callid;

        dump('connect ' + address + '\n');
        var parts = address.split('@');
        if (parts.length != 2) {
            cont.abort(SERVER_NOT_FOUND);
            return;
        }
        var username = parts[0];
        var hostname = parts[1];
        var transport = sts.createTransport(null, 0, hostname, 7277, null);
        var writer = transport.openOutputStream(0, 0, 0);
        var stream = transport.openInputStream(0, 0, 0);
        var reader =
            XPO('scriptableinputstream;1', XPI.nsIScriptableInputStream);
        reader.init(stream);

        var conn = new function() {
            var buffer = '';
            this.receiver = NULL_CONTINUATION;
            this.encrypt = null;
            this.decrypt = null;

            this.send = function() {
                for (var i = 0; i < arguments.length; i++) {
                    var message = arguments[i] + '';
                    if (this.encrypt) message = this.encrypt(message);
                    dump('-> ' + message + '\n');
                    writer.write(message + '\n', message.length + 1);
                }
            }

            this.close = function() {
                this.receiver = NULL_CONTINUATION;
                this.encrypt = null;
                this.decrypt = null;
                reader.close();
                writer.close();
            };

            this.onStartRequest = function(request, context) { };

            this.onStopRequest = function(request, context, status) {
                var receiver = this.receiver;
                this.close();
                receiver.abort(CONNECTION_ERROR);
            };

            function get(line) {
                switch (line.charAt(0)) {
                    case '+':
                        return line.substring(1);
                        break;
                    case '-':
                        switch (line.split(' ')[0]) {
                            case '-protocol':
                                throw SERVER_NOT_FOUND;
                            case '-login':
                                throw LOGIN_FAILED;
                            case '-write':
                                throw WRITE_REFUSED;
                            default:
                                throw INVALID_REQUEST;
                        }
                    case '!':
                        throw SERVER_ERROR;
                    default:
                        throw CONNECTION_ERROR;
                }
            }

            this.onDataAvailable = function(
                request, context, stream, offset, count) {
                try {
                    try {
                        buffer += reader.read(count);
                    } catch (e) {
                        throw CONNECTION_ERROR;
                    }
                    var newline = buffer.indexOf('\n');
                    while (newline >= 0) {
                        var line = buffer.substring(0, newline);
                        buffer = buffer.substring(newline + 1);
                        dump('<- ' + line + '\n');
                        if (this.decrypt) {
                            try {
                                line = this.decrypt(get(line));
                            } catch (e) {
                                throw CONNECTION_ERROR;
                            }
                        }
                        this.receiver.proceed(get(line));
                        newline = buffer.indexOf('\n');
                    }
                } catch (error) {
                    var receiver = this.receiver;
                    this.close();
                    receiver.abort(error);
                }
            };
        };

        conn.receiver = new Cont(cid + '-connect1', function(line) {
            if (line.substring(0, 8) == 'Passpet ') {
                conn.send(1); // request a protocol version
                conn.receiver = new Cont(cid + '-connect2', function(line) {
                    conn.receiver = NULL_CONTINUATION;
                    cont.proceed(conn, username);
                }, cont, SERVER_NOT_FOUND);
            } else throw SERVER_NOT_FOUND;
        }, cont, SERVER_NOT_FOUND);

        var pump = XPO('network/input-stream-pump;1', XPI.nsIInputStreamPump);
        pump.init(stream, -1, -1, 0, 0, false);
        pump.asyncRead(conn, null);
    };

    this.create = function(address, k1, password, cont) {
        var cid = ++callid;
        connect(address, new Cont(cid + '-create1', function(conn, username) {
            dump('create ' + address + ' ' + k1 + '\n');
            var setup = new srp.setup(password);
            conn.send('create', username, k1, setup.salt, setup.verifier);
            conn.receiver = new Cont(cid + '-create2', function(line) {
                conn.close();
                cont.proceed(line);
            }, cont);
        }, cont));
    };

    this.list = function(address, cont) {
        var cid = ++callid;
        connect(address, new Cont(cid + '-list1', function(conn, username) {
            dump('list ' + address + '\n');
            conn.send('list', username);
            conn.receiver = new Cont(cid + '-list2', function(line) {
                conn.close();
                cont.proceed(line);
            }, cont);
        }, cont));
    };

    function login(conn, username, index, password, cont) {
        var cid = ++callid;
        dump('login ' + username + ' ' + index + '\n');
        conn.send('login', username, index);
        var login = new srp.login(username, password, conn.send);
        conn.receiver = new Cont(cid + '-login1', function(line) {
            if (!login.done) {
                try {
                    login.proceed(line);
                } catch (error) {
                    dump('login error: ' + error + '\n');
                    conn.close();
                    cont.abort(LOGIN_FAILED);
                }
            } else {
                var nonce = szr.fromHex(line);
                b = nonce.charCodeAt(nonce.length - 1) ^ 1;
                var nonce2 = nonce.substring(0, nonce.length - 1) + chr(b);
                var cipher = XPO('@passpet.org/aes;1', XPI.IPasspetAES);
                cipher.init(128, login.key);
                var inmode = XPO('@passpet.org/gcm;1', XPI.IPasspetGCM);
                inmode.initWithNonce(cipher, nonce);
                function decrypt(message) {
                    return inmode.decrypt(szr.fromHex(message));
                }
                var outmode = XPO('@passpet.org/gcm;1', XPI.IPasspetGCM);
                outmode.initWithNonce(cipher, nonce2);
                function encrypt(message) {
                    return szr.toHex(outmode.encrypt(message));
                }
                conn.receiver = NULL_CONTINUATION;
                cont.proceed(encrypt, decrypt);
            }
        }, cont);
    }

    this.delete_ = function(address, index, password, cont) {
        var cid = ++callid;
        connect(address, new Cont(cid + '-delete1', function(conn, username) {
            login(conn, username, index, password,
                  new Cont(cid + '-delete2', function(encrypt, decrypt) {
                dump('delete ' + address + ' ' + index + '\n');
                conn.encrypt = encrypt;
                conn.decrypt = decrypt;
                conn.send('delete');
                conn.receiver = new Cont(cid + '-delete3', function(line) {
                    conn.close();
                    cont.proceed(line);
                }, cont);
            }, cont));
        }, cont));
    };

    this.read = function(address, index, password, cont) {
        var cid = ++callid;
        connect(address, new Cont(cid + '-read1', function(conn, username) {
            login(conn, username, index, password,
                  new Cont(cid + '-read2', function(encrypt, decrypt) {
                dump('read ' + address + ' ' + index + '\n');
                conn.encrypt = encrypt;
                conn.decrypt = decrypt;
                conn.send('read');
                conn.receiver = new Cont(cid + '-read3', function(line) {
                    conn.close();
                    cont.proceed(line);
                }, cont);
            }, cont));
        }, cont));
    };

    this.write = function(address, index, password, oldMAC, newFile, cont) {
        var cid = ++callid;
        connect(address, new Cont(cid + '-write1', function(conn, username) {
            login(conn, username, index, password,
                  new Cont(cid + '-write2', function(encrypt, decrypt) {
                dump('write ' + address + ' ' + index + '\n');
                conn.encrypt = encrypt;
                conn.decrypt = decrypt;
                conn.send('write', oldMAC, newFile);
                conn.receiver = new Cont(cid + '-write3', function(line) {
                    conn.close();
                    cont.proceed(line);
                }, cont);
            }, cont));
        }, cont));
    };

    // This method implements the nsISupports interface.
    this.QueryInterface = function(iid) {
        if (iid.equals(XPI.IPasspetRemote)) return this;
        if (iid.equals(XPI.nsISupports)) return this;
        throw XPR.NS_ERROR_NO_INTERFACE;
    };
};

