package net.i2p.router.crypto;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.security.KeyStore;
import java.security.GeneralSecurityException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.cert.X509Certificate;
import java.security.cert.X509CRL;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

import net.i2p.crypto.CertUtil;
import net.i2p.crypto.KeyStoreUtil;
import net.i2p.crypto.SigType;
import net.i2p.crypto.SigUtil;
import net.i2p.data.Base64;
import net.i2p.data.DataHelper;
import net.i2p.data.Hash;
import net.i2p.data.Signature;
import net.i2p.data.SigningPrivateKey;
import net.i2p.data.SigningPublicKey;
import net.i2p.data.router.RouterInfo;
import net.i2p.router.RouterContext;
import net.i2p.router.StatisticsManager;
import net.i2p.util.ConcurrentHashSet;
import net.i2p.util.Log;
import net.i2p.util.SecureDirectory;

/**
 * Utilities for creating, storing, retrieving the signing keys for
 * the netdb family feature
 *
 * @since 0.9.24
 */
public class FamilyKeyCrypto {

    private final RouterContext _context;
    private final Log _log;
    private final Map<Hash, String> _verified;
    private final Set<Hash> _negativeCache;
    private final Set<Hash> _ourFamily;
    // following for verification only, otherwise null
    private final String _fname;
    private final SigningPrivateKey _privkey;
    private final SigningPublicKey _pubkey;

    public static final String PROP_KEYSTORE_PASSWORD = "netdb.family.keystorePassword";
    public static final String PROP_FAMILY_NAME = "netdb.family.name";
    public static final String PROP_KEY_PASSWORD = "netdb.family.keyPassword";
    public static final String CERT_SUFFIX = ".crt";
    public static final String CRL_SUFFIX = ".crl";
    public static final String KEYSTORE_PREFIX = "family-";
    public static final String KEYSTORE_SUFFIX = ".ks";
    public static final String CN_SUFFIX = ".family.i2p.net";
    private static final int DEFAULT_KEY_VALID_DAYS = 3652;  // 10 years
    // Note that we can't use RSA here, as the b64 sig would exceed the 255 char limit for a Mapping
    // Note that we can't use EdDSA here, as keystore doesn't know how, and encoding/decoding is unimplemented
    private static final String DEFAULT_KEY_ALGORITHM = SigType.ECDSA_SHA256_P256.isAvailable() ? "EC" : "DSA";
    private static final int DEFAULT_KEY_SIZE = SigType.ECDSA_SHA256_P256.isAvailable() ? 256 : 1024;
    //private static final String DEFAULT_KEY_ALGORITHM = "EdDSA";
    //private static final int DEFAULT_KEY_SIZE = 256;
    private static final String KS_DIR = "keystore";
    private static final String CERT_DIR = "certificates/family";
    private static final String CRL_DIR = "crls";
    public static final String OPT_NAME = "family";
    public static final String OPT_SIG = "family.sig";
    public static final String OPT_KEY = "family.key";


    /** 
     *  For signing and verification.
     *
     *  If the context property netdb.family.name is set, this can be used for signing,
     *  else only for verification.
     */
    public FamilyKeyCrypto(RouterContext context) throws GeneralSecurityException {
        _context = context;
        _log = _context.logManager().getLog(FamilyKeyCrypto.class);
        _fname = _context.getProperty(PROP_FAMILY_NAME);
        if (_fname != null) {
            if (_fname.contains("/") || _fname.contains("\\") ||
                _fname.contains("..") || (new File(_fname)).isAbsolute() ||
                _fname.length() <= 0)
                throw new GeneralSecurityException("Illegal family name: " + _fname);
        }
        _privkey = (_fname != null) ? initialize() : null;
        _pubkey = (_privkey != null) ? _privkey.toPublic() : null;
        _verified = new ConcurrentHashMap<Hash, String>(4);
        _negativeCache = new ConcurrentHashSet<Hash>(4);
        _ourFamily = (_privkey != null) ? new ConcurrentHashSet<Hash>(4) : Collections.<Hash>emptySet();
    }
    
    /** 
     * Create (if necessary) and load the key store, then run.
     */
    private SigningPrivateKey initialize() throws GeneralSecurityException {
        File dir = new SecureDirectory(_context.getConfigDir(), KS_DIR);
        File keyStore = new File(dir, KEYSTORE_PREFIX + _fname + KEYSTORE_SUFFIX);
        verifyKeyStore(keyStore);
        return getPrivKey(keyStore);
    }
    
    /** 
     * Clears the caches
     */
    public void shutdown() {
        _verified.clear();
        _negativeCache.clear();
    }

    /** 
     *  Caller must add family to RI also.
     *  throws on all errors
     *
     *  @param family non-null, must match that we were initialized with or will throw GSE
     *  @param h non-null
     *  @return non-null options to be added to the RI
     *  @throws GeneralSecurityException on null hash, null or changed family, or signing error
     */
    public Map<String, String> sign(String family, Hash h) throws GeneralSecurityException {
        if (_privkey == null) {
            _log.logAlways(Log.WARN, "family name now set, must restart router to generate or load keys");
            throw new GeneralSecurityException("family name now set, must restart router to generate or load keys");
        }
        if (h == null)
            throw new GeneralSecurityException("null router hash");
        if (!_fname.equals(family)) {
            _log.logAlways(Log.WARN, "family name changed, must restart router to generate or load new keys");
            throw new GeneralSecurityException("family name changed, must restart router to generate or load new keys");
        }
        byte[] nb = DataHelper.getUTF8(_fname);
        int len = nb.length + Hash.HASH_LENGTH;
        byte[] b = new byte[len];
        System.arraycopy(nb, 0, b, 0, nb.length);
        System.arraycopy(h.getData(), 0, b, nb.length, Hash.HASH_LENGTH);
        Signature sig = _context.dsa().sign(b, _privkey);
        if (sig == null)
            throw new GeneralSecurityException("sig failed");
        Map<String, String> rv = new HashMap<String, String>(3);
        rv.put(OPT_NAME, family);
        rv.put(OPT_KEY, _pubkey.getType().getCode() + ":" + _pubkey.toBase64());
        rv.put(OPT_SIG, sig.toBase64());
        return rv;
    }

    /** 
     *  Do we have a valid family?
     *  @since 0.9.28
     */
    public boolean hasFamily() {
        return _pubkey != null;
    }

    /** 
     *  Get verified members of our family.
     *  Will not contain ourselves.
     *
     *  @return non-null, not a copy, do not modify
     *  @since 0.9.28
     */
    public Set<Hash> getOurFamily() {
        return _ourFamily;
    }

    /** 
     *  Get our family name.
     *
     *  @return name or null
     *  @since 0.9.28
     */
    public String getOurFamilyName() {
        return _fname;
    }

    /** 
     *  Verify the family signature in a RouterInfo.
     *  @return true if good sig or if no family specified at all
     */
    public boolean verify(RouterInfo ri) {
        String name = ri.getOption(OPT_NAME);
        if (name == null)
            return true;
        return verify(ri, name);
    }

    /** 
     *  Verify the family in a RouterInfo matches ours and the signature is good.
     *  Returns false if we don't have a family and sig, or they don't.
     *  Returns false for ourselves.
     *
     *  @return true if family matches with good sig
     *  @since 0.9.28
     */
    public boolean verifyOurFamily(RouterInfo ri) {
        if (_pubkey == null)
            return false;
        String name = ri.getOption(OPT_NAME);
        if (!_fname.equals(name))
            return false;
        Hash h = ri.getHash();
        if (_ourFamily.contains(h))
            return true;
        if (h.equals(_context.routerHash()))
            return false;
        boolean rv = verify(ri, name);
        if (rv) {
            _ourFamily.add(h);
            _log.logAlways(Log.INFO, "Found and verified member of our family (" + _fname + "): " + h);
        } else {
            if (_log.shouldWarn())
                _log.warn("Found spoofed member of our family (" + _fname + "): " + h);
        }
        return rv;
    }

    /** 
     *  Verify the family in a RouterInfo, name already retrieved
     *  @since 0.9.28
     */
    private boolean verify(RouterInfo ri, String name) {
        Hash h = ri.getHash();
        String ssig = ri.getOption(OPT_SIG);
        if (ssig == null) {
            if (_log.shouldInfo())
                _log.info("No sig for " + h + ' ' + name);
            return false;
        }
        String nameAndSig = _verified.get(h);
        String riNameAndSig = name + ssig;
        if (nameAndSig != null) {
            if (nameAndSig.equals(riNameAndSig))
                return true;
            // name or sig changed
            _verified.remove(h);
        }
        SigningPublicKey spk;
        if (name.equals(_fname)) {
            // us
            spk = _pubkey;
        } else {
            if (_negativeCache.contains(h))
                return false;
            spk = loadCert(name);
            if (spk == null) {
                // look for a b64 key in the RI
                String skey = ri.getOption(OPT_KEY);
                if (skey != null) {
                    int colon = skey.indexOf(':');
                    // switched from ';' to ':' during dev, remove this later
                    if (colon < 0)
                        colon = skey.indexOf(';');
                    if (colon > 0) {
                        try {
                            int code = Integer.parseInt(skey.substring(0, colon));
                            SigType type = SigType.getByCode(code);
                            if (type != null) {
                                byte[] bkey = Base64.decode(skey.substring(colon + 1));
                                if (bkey != null) {
                                    spk = new SigningPublicKey(type, bkey);
                                }
                            }
                        } catch (NumberFormatException e) {
                            if (_log.shouldInfo())
                                _log.info("Bad b64 family key: " + ri, e);
                        } catch (IllegalArgumentException e) {
                            if (_log.shouldInfo())
                                _log.info("Bad b64 family key: " + ri, e);
                        } catch (ArrayIndexOutOfBoundsException e) {
                            if (_log.shouldInfo())
                                _log.info("Bad b64 family key: " + ri, e);
                        }
                    }
                }
                if (spk == null) {
                    _negativeCache.add(h);
                    if (_log.shouldInfo())
                        _log.info("No cert or valid key for " + h + ' ' + name);
                    return false;
                }
            }
        }
        if (!spk.getType().isAvailable()) {
            _negativeCache.add(h);
            if (_log.shouldInfo())
                _log.info("Unsupported crypto for sig for " + h);
            return false;
        }
        byte[] bsig = Base64.decode(ssig);
        if (bsig == null) {
            _negativeCache.add(h);
            if (_log.shouldInfo())
                _log.info("Bad sig for " + h + ' ' + name + ' ' + ssig);
            return false;
        }
        Signature sig;
        try {
            sig = new Signature(spk.getType(), bsig);
        } catch (IllegalArgumentException iae) {
            // wrong size (type mismatch)
            _negativeCache.add(h);
            if (_log.shouldInfo())
                _log.info("Bad sig for " + ri, iae);
            return false;
        }
        byte[] nb = DataHelper.getUTF8(name);
        byte[] b = new byte[nb.length + Hash.HASH_LENGTH];
        System.arraycopy(nb, 0, b, 0, nb.length);
        System.arraycopy(ri.getHash().getData(), 0, b, nb.length, Hash.HASH_LENGTH);
        boolean rv = _context.dsa().verifySignature(sig, b, spk);
        if (rv)
            _verified.put(h, riNameAndSig);
        else
            _negativeCache.add(h);
        if (_log.shouldInfo())
            _log.info("Verified? " + rv + " for " + h + ' ' + name + ' ' + ssig);
        return rv;
    }

    /**
     *  @return success if it exists and we have a password, or it was created successfully.
     *  @throws GeneralSecurityException on keystore error
     */
    private void verifyKeyStore(File ks) throws GeneralSecurityException {
        if (ks.exists()) {
            if (_context.getProperty(PROP_KEY_PASSWORD) == null) {
                String s ="Family key error, must set " + PROP_KEY_PASSWORD + " in " +
                          (new File(_context.getConfigDir(), "router.config")).getAbsolutePath();
                _log.error(s);
                throw new GeneralSecurityException(s);
            }
            return;
        }
        File dir = ks.getParentFile();
        if (!dir.exists()) {
            File sdir = new SecureDirectory(dir.getAbsolutePath());
            if (!sdir.mkdirs()) {
                String s ="Family key error, must set " + PROP_KEY_PASSWORD + " in " +
                          (new File(_context.getConfigDir(), "router.config")).getAbsolutePath();
                _log.error(s);
                throw new GeneralSecurityException(s);
            }
        }

        try {
            createKeyStore(ks);
        } catch (IOException ioe) {
            throw new GeneralSecurityException("Failed to create NetDb family keystore", ioe);
        }
    }


    /**
     * Call out to keytool to create a new keystore with a keypair in it.
     * Trying to do this programatically is a nightmare, requiring either BouncyCastle
     * libs or using proprietary Sun libs, and it's a huge mess.
     * If successful, stores the keystore password and key password in router.config.
     *
     * @throws GeneralSecurityException on all errors
     */
    private void createKeyStore(File ks) throws GeneralSecurityException, IOException {
        // make a random 48 character password (30 * 8 / 5)
        String keyPassword = KeyStoreUtil.randomString();
        // and one for the cname
        String cname = _fname + CN_SUFFIX;

        Object[] rv = KeyStoreUtil.createKeysAndCRL(ks, KeyStoreUtil.DEFAULT_KEYSTORE_PASSWORD, _fname, cname, "family",
                                                  DEFAULT_KEY_VALID_DAYS, DEFAULT_KEY_ALGORITHM,
                                                  DEFAULT_KEY_SIZE, keyPassword);

                Map<String, String> changes = new HashMap<String, String>();
                changes.put(PROP_KEYSTORE_PASSWORD, KeyStoreUtil.DEFAULT_KEYSTORE_PASSWORD);
                changes.put(PROP_KEY_PASSWORD, keyPassword);
                changes.put(PROP_FAMILY_NAME, _fname);
                _context.router().saveConfig(changes, null);

            _log.logAlways(Log.INFO, "Created new private key for netdb family \"" + _fname +
                           "\" in keystore: " + ks.getAbsolutePath() + "\n" +
                           "Copy the keystore to the other routers in the family,\n" +
                           "and add the following entries to their router.config file:\n" +
                           PROP_FAMILY_NAME + '=' + _fname + '\n' +
                           PROP_KEYSTORE_PASSWORD + '=' + KeyStoreUtil.DEFAULT_KEYSTORE_PASSWORD + '\n' +
                           PROP_KEY_PASSWORD + '=' + keyPassword);

        X509Certificate cert = (X509Certificate) rv[2];
        exportCert(cert);
        X509CRL crl = (X509CRL) rv[3];
        exportCRL(ks.getParentFile(), crl);
    }

    /** 
     * Save the public key certificate
     * so the clients can get to it.
     */
    private void exportCert(X509Certificate cert) {
        File sdir = new SecureDirectory(_context.getConfigDir(), CERT_DIR);
        if (sdir.exists() || sdir.mkdirs()) {
            String name = _fname.replace("@", "_at_") + CERT_SUFFIX;
            File out = new File(sdir, name);
            boolean success = CertUtil.saveCert(cert, out);
            if (success) {
                _log.logAlways(Log.INFO, "Created new public key certificate for netdb family \"" + _fname +
                           "\" in file: " + out.getAbsolutePath() + "\n" +
                           "The certificate will be associated with your router identity.\n" +
                           "Copy the certificate to the directory $I2P/" + CERT_DIR + " for each of the other routers in the family.\n" +
                           "Give this certificate to an I2P developer for inclusion in the next I2P release.");
            } else {
                _log.error("Error saving family key certificate");
            }
        } else {
            _log.error("Error saving family key certificate");
        }
    }

    /** 
     * Save the CRL just in case.
     * @param ksdir parent of directory to save in
     * @since 0.9.25
     */
    private void exportCRL(File ksdir, X509CRL crl) {
        File sdir = new SecureDirectory(ksdir, CRL_DIR);
        if (sdir.exists() || sdir.mkdirs()) {
            String name = KEYSTORE_PREFIX + _fname.replace("@", "_at_") + '-' + System.currentTimeMillis() + CRL_SUFFIX;
            File out = new File(sdir, name);
            boolean success = CertUtil.saveCRL(crl, out);
            if (success) {
                _log.logAlways(Log.INFO, "Created certificate revocation list (CRL) for netdb family \"" + _fname +
                           "\" in file: " + out.getAbsolutePath() + "\n" +
                           "Back up the keystore and CRL files and keep them secure.\n" +
                           "If your private key is ever compromised, give the CRL to an I2P developer for publication.");
            } else {
                _log.error("Error saving family key CRL");
            }
        } else {
            _log.error("Error saving family key CRL");
        }
    }

    /** 
     * Load a public key from a cert.
     *
     * @return null on all errors
     */
    private SigningPublicKey loadCert(String familyName) {
        if (familyName.contains("/") || familyName.contains("\\") ||
            familyName.contains("..") || (new File(familyName)).isAbsolute())
            return null;
        familyName = familyName.replace("@", "_at_");
        File dir = new File(_context.getBaseDir(), CERT_DIR);
        File file = new File(dir, familyName + CERT_SUFFIX);
        if (!file.exists())
            return null;
        try {
            PublicKey pk = CertUtil.loadKey(file);
            return SigUtil.fromJavaKey(pk);
        } catch (GeneralSecurityException gse) {
            _log.error("Error loading family key " + familyName, gse);
        } catch (IOException ioe) {
            _log.error("Error loading family key " + familyName, ioe);
        }
        return null;
    }

    /** 
     * Get the private key from the keystore
     * @return non-null, throws on all errors
     */
    private SigningPrivateKey getPrivKey(File ks) throws GeneralSecurityException {
        String ksPass = _context.getProperty(PROP_KEYSTORE_PASSWORD, KeyStoreUtil.DEFAULT_KEYSTORE_PASSWORD);
        String keyPass = _context.getProperty(PROP_KEY_PASSWORD);
        if (keyPass == null)
            throw new GeneralSecurityException("No key password, set " + PROP_KEY_PASSWORD +
                       " in " + (new File(_context.getConfigDir(), "router.config")).getAbsolutePath());
        try {
            PrivateKey pk = KeyStoreUtil.getPrivateKey(ks, ksPass, _fname, keyPass);
            if (pk == null)
                throw new GeneralSecurityException("Family key not found: " + _fname);
            return SigUtil.fromJavaKey(pk);
        } catch (IOException ioe) {
            throw new GeneralSecurityException("Error loading family key " + _fname, ioe);
        }
    }
}
