/*
 * Copyright (c) 2000, 2024, Oracle and/or its affiliates. All rights reserved.
 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
 *
 * This code is free software; you can redistribute it and/or modify it
 * under the terms of the GNU General Public License version 2 only, as
 * published by the Free Software Foundation.  Oracle designates this
 * particular file as subject to the "Classpath" exception as provided
 * by Oracle in the LICENSE file that accompanied this code.
 *
 * This code is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
 * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
 * version 2 for more details (a copy is included in the LICENSE file that
 * accompanied this code).
 *
 * You should have received a copy of the GNU General Public License version
 * 2 along with this work; if not, write to the Free Software Foundation,
 * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
 *
 * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
 * or visit www.oracle.com if you need additional information or have any
 * questions.
 */

/*
 *
 *  (C) Copyright IBM Corp. 1999 All Rights Reserved.
 *  Copyright 1997 The Open Group Research Institute.  All rights reserved.
 */

package sun.security.krb5.internal.ktab;

import sun.security.krb5.*;
import sun.security.krb5.internal.*;
import sun.security.krb5.internal.crypto.*;
import java.util.ArrayList;
import java.util.Arrays;
import java.io.IOException;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.util.Comparator;
import java.util.HashMap;
import java.util.Map;
import java.util.StringTokenizer;
import java.util.Vector;
import sun.security.jgss.krb5.ServiceCreds;

import static sun.security.krb5.internal.Krb5.DEBUG;

/**
 * This class represents key table. The key table functions deal with storing
 * and retrieving service keys for use in authentication exchanges.
 *
 * A KeyTab object is always constructed, if the file specified does not
 * exist, it's still valid but empty. If there is an I/O error or file format
 * error, it's invalid.
 *
 * The class is immutable on the read side (the write side is only used by
 * the ktab tool).
 *
 * @author Yanni Zhang
 */
public class KeyTab implements KeyTabConstants {

    private static String defaultTabName = null;

    // Attention: Currently there is no way to remove a keytab from this map,
    // this might lead to a memory leak.
    private static Map<String,KeyTab> map = new HashMap<>();

    // KeyTab file does not exist. Note: a missing keytab is still valid
    private boolean isMissing = false;

    // KeyTab file is invalid, possibly an I/O error or a file format error.
    private boolean isValid = true;

    private final String tabName;
    private long lastModified;
    private int kt_vno = KRB5_KT_VNO;

    private Vector<KeyTabEntry> entries = new Vector<>();

    /**
     * Constructs a KeyTab object.
     *
     * If there is any I/O error or format error during the loading, the
     * isValid flag is set to false, and all half-read entries are dismissed.
     * @param filename path name for the keytab file, must not be null
     */
    private KeyTab(String filename) {
        tabName = filename;
        try {
            lastModified = new File(tabName).lastModified();
            try (KeyTabInputStream kis =
                    new KeyTabInputStream(new FileInputStream(filename))) {
                load(kis);
            }
        } catch (FileNotFoundException e) {
            entries.clear();
            isMissing = true;
            if (DEBUG != null) {
                DEBUG.println("Cannot load keytab " + tabName + ": " + e);
            }
        } catch (Exception ioe) {
            entries.clear();
            isValid = false;
            if (DEBUG != null) {
                DEBUG.println("Cannot load keytab " + tabName + ": " + ioe);
            }
        }
    }

    /**
     * Read a keytab file. Returns a new object and save it into cache when
     * new content (modified since last read) is available. If keytab file is
     * invalid, the old object will be returned. This is a safeguard for
     * partial-written keytab files or non-stable network. Please note that
     * a missing keytab is valid, which is equivalent to an empty keytab.
     *
     * @param s file name of keytab, must not be null
     * @return the keytab object, can be invalid, but never null.
     */
    private static synchronized KeyTab getInstance0(String s) {
        long lm = new File(s).lastModified();
        KeyTab old = map.get(s);
        if (old != null && old.isValid() && old.lastModified == lm) {
            return old;
        }
        KeyTab ktab = new KeyTab(s);
        if (ktab.isValid()) {               // A valid new keytab
            map.put(s, ktab);
            return ktab;
        } else if (old != null) {           // An existing old one
            return old;
        } else {
            return ktab;                    // first read is invalid
        }
    }

    /**
     * Gets a KeyTab object.
     * @param s the key tab file name.
     * @return the KeyTab object, never null.
     */
    public static KeyTab getInstance(String s) {
        if (s == null) {
            return getInstance();
        } else {
            return getInstance0(normalize(s));
        }
    }

    /**
     * Gets a KeyTab object.
     * @param file the key tab file.
     * @return the KeyTab object, never null.
     */
    public static KeyTab getInstance(File file) {
        if (file == null) {
            return getInstance();
        } else {
            return getInstance0(file.getPath());
        }
    }

    /**
     * Gets the default KeyTab object.
     * @return the KeyTab object, never null.
     */
    public static KeyTab getInstance() {
        return getInstance(getDefaultTabName());
    }

    public boolean isMissing() {
        return isMissing;
    }

    public boolean isValid() {
        return isValid;
    }

    /**
     * The location of keytab file will be read from the configuration file
     * If it is not specified, consider user.home as the keytab file's
     * default location.
     * @return never null
     */
    private static String getDefaultTabName() {
        if (defaultTabName != null) {
            return defaultTabName;
        } else {
            String kname = null;
            try {
                String keytab_names = Config.getInstance().get
                        ("libdefaults", "default_keytab_name");
                if (keytab_names != null) {
                    StringTokenizer st = new StringTokenizer(keytab_names, " ");
                    while (st.hasMoreTokens()) {
                        kname = normalize(st.nextToken());
                        if (new File(kname).exists()) {
                            break;
                        }
                    }
                }
            } catch (KrbException e) {
                kname = null;
            }

            if (kname == null) {
                String user_home = System.getProperty("user.home");

                if (user_home == null) {
                    user_home = System.getProperty("user.dir");
                }

                kname = user_home + File.separator  + "krb5.keytab";
            }
            defaultTabName = kname;
            return kname;
        }
    }

    /**
     * Normalizes some common keytab name formats into the bare file name.
     * For example, FILE:/etc/krb5.keytab to /etc/krb5.keytab
     * @param name never null
     * @return never null
     */
    // This method is used in this class and Krb5LoginModule
    public static String normalize(String name) {
        String kname;
        if ((name.length() >= 5) &&
            (name.substring(0, 5).equalsIgnoreCase("FILE:"))) {
            kname = name.substring(5);
        } else if ((name.length() >= 9) &&
                (name.substring(0, 9).equalsIgnoreCase("ANY:FILE:"))) {
            // this format found in MIT's krb5.ini.
            kname = name.substring(9);
        } else if ((name.length() >= 7) &&
                (name.substring(0, 7).equalsIgnoreCase("SRVTAB:"))) {
            // this format found in MIT's krb5.ini.
            kname = name.substring(7);
        } else
            kname = name;
        return kname;
    }

    private void load(KeyTabInputStream kis)
        throws IOException, RealmException {

        entries.clear();
        kt_vno = kis.readVersion();
        if (kt_vno == KRB5_KT_VNO_1) {
            kis.setNativeByteOrder();
        }
        int entryLength = 0;
        KeyTabEntry entry;
        while (kis.available() > 0) {
            entryLength = kis.readEntryLength();
            entry = kis.readEntry(entryLength, kt_vno);
            if (DEBUG != null) {
                DEBUG.println(">>> KeyTab: load() entry length: " +
                        entryLength + "; type: " +
                        (entry != null? entry.keyType : 0));
            }
            if (entry != null)
                entries.addElement(entry);
        }
    }

    /**
     * Returns a principal name in this keytab. Used by
     * {@link ServiceCreds#getKKeys()}.
     */
    public PrincipalName getOneName() {
        int size = entries.size();
        return size > 0 ? entries.elementAt(size-1).service : null;
    }

    /**
     * Reads all keys for a service from the keytab file that have
     * etypes that have been configured for use.
     * @param service the PrincipalName of the requested service
     * @return an array containing all the service keys, never null
     */
    public EncryptionKey[] readServiceKeys(PrincipalName service) {
        KeyTabEntry entry;
        EncryptionKey key;
        int size = entries.size();
        ArrayList<EncryptionKey> keys = new ArrayList<>(size);
        if (DEBUG != null) {
            DEBUG.println("Looking for keys for: " + service);
        }
        for (int i = size-1; i >= 0; i--) {
            entry = entries.elementAt(i);
            if (entry.service.match(service)) {
                if (EType.isSupported(entry.keyType)) {
                    key = new EncryptionKey(entry.keyblock,
                                        entry.keyType,
                                        entry.keyVersion);
                    keys.add(key);
                    if (DEBUG != null) {
                        DEBUG.println("Added key: " + entry.keyType +
                            ", version: " + entry.keyVersion);
                    }
                } else if (DEBUG != null) {
                    DEBUG.println("Found unsupported keytype (" +
                        entry.keyType + ") for " + service);
                }
            }
        }
        size = keys.size();
        EncryptionKey[] retVal = keys.toArray(new EncryptionKey[size]);

        // Sort the keys by kvno. Sometimes we must choose a single key (say,
        // generate encrypted timestamp in AS-REQ). A key with a higher KVNO
        // sounds like a newer one.
        Arrays.sort(retVal, new Comparator<EncryptionKey>() {
            @Override
            public int compare(EncryptionKey o1, EncryptionKey o2) {
                return o2.getKeyVersionNumber().intValue()
                        - o1.getKeyVersionNumber().intValue();
            }
        });

        return retVal;
    }



    /**
     * Searches for the service entry in the keytab file.
     * The etype of the key must be one that has been configured
     * to be used.
     * @param service the PrincipalName of the requested service.
     * @return true if the entry is found, otherwise, return false.
     */
    public boolean findServiceEntry(PrincipalName service) {
        KeyTabEntry entry;
        for (int i = 0; i < entries.size(); i++) {
            entry = entries.elementAt(i);
            if (entry.service.match(service)) {
                if (EType.isSupported(entry.keyType)) {
                    return true;
                } else if (DEBUG != null) {
                    DEBUG.println("Found unsupported keytype (" +
                        entry.keyType + ") for " + service);
                }
            }
        }
        return false;
    }

    public String tabName() {
        return tabName;
    }

    /////////////////// THE WRITE SIDE ///////////////////////
    /////////////// only used by ktab tool //////////////////

    /**
     * Adds a new entry in the key table.
     * @param service the service which will have a new entry in the key table.
     * @param psswd the password which generates the key.
     * @param kvno the kvno to use, -1 means automatic increasing
     * @param append false if entries with old kvno would be removed.
     * Note: if kvno is not -1, entries with the same kvno are always removed
     */
    public void addEntry(PrincipalName service, char[] psswd,
            int kvno, boolean append) throws KrbException {
        addEntry(service, service.getSalt(), psswd, kvno, append);
    }

    /**
     * Adds a new entry in the key table.
     * @param service the service which will have a new entry in the key table.
     * @param salt specified non default salt, cannot be null
     * @param psswd the password which generates the key.
     * @param kvno the kvno to use, -1 means automatic increasing
     * @param append false if entries with old kvno would be removed.
     * Note: if kvno is not -1, entries with the same kvno are always removed
     */
    public void addEntry(PrincipalName service, String salt, char[] psswd,
            int kvno, boolean append) throws KrbException {

        EncryptionKey[] encKeys = EncryptionKey.acquireSecretKeys(
                psswd, salt);
        addEntry(service, encKeys, kvno, append);
    }

    /**
     * Adds a new entry in the key table.
     * @param service the service which will have a new entry in the key table.
     * @param encKeys the keys to be added
     * @param kvno the kvno to use, -1 means automatic increasing
     * @param append false if entries with old kvno would be removed.
     * Note: if kvno is not -1, entries with the same kvno are always removed
     */
    public void addEntry(PrincipalName service, EncryptionKey[] encKeys,
            int kvno, boolean append) throws KrbException {

        // There should be only one maximum KVNO value for all etypes, so that
        // all added keys can have the same KVNO.

        int maxKvno = 0;    // only useful when kvno == -1
        for (int i = entries.size()-1; i >= 0; i--) {
            KeyTabEntry e = entries.get(i);
            if (e.service.match(service)) {
                if (e.keyVersion > maxKvno) {
                    maxKvno = e.keyVersion;
                }
                if (!append || e.keyVersion == kvno) {
                    entries.removeElementAt(i);
                }
            }
        }
        if (kvno == -1) {
            kvno = maxKvno + 1;
        }

        for (int i = 0; encKeys != null && i < encKeys.length; i++) {
            int keyType = encKeys[i].getEType();
            byte[] keyValue = encKeys[i].getBytes();

            KeyTabEntry newEntry = new KeyTabEntry(service,
                            service.getRealm(),
                            new KerberosTime(System.currentTimeMillis()),
                                               kvno, keyType, keyValue);
            entries.addElement(newEntry);
        }
    }

    /**
     * Gets the list of service entries in key table.
     * @return array of <code>KeyTabEntry</code>.
     */
    public KeyTabEntry[] getEntries() {
        KeyTabEntry[] kentries = new KeyTabEntry[entries.size()];
        for (int i = 0; i < kentries.length; i++) {
            kentries[i] = entries.elementAt(i);
        }
        return kentries;
    }

    /**
     * Creates a new default key table.
     */
    public static synchronized KeyTab create()
        throws IOException, RealmException {
        String dname = getDefaultTabName();
        return create(dname);
    }

    /**
     * Creates a new default key table.
     */
    public static synchronized KeyTab create(String name)
        throws IOException, RealmException {

        try (KeyTabOutputStream kos =
                new KeyTabOutputStream(new FileOutputStream(name))) {
            kos.writeVersion(KRB5_KT_VNO);
        }
        return new KeyTab(name);
    }

    /**
     * Saves the file at the directory.
     */
    public synchronized void save() throws IOException {
        try (KeyTabOutputStream kos =
                new KeyTabOutputStream(new FileOutputStream(tabName))) {
            kos.writeVersion(kt_vno);
            for (int i = 0; i < entries.size(); i++) {
                kos.writeEntry(entries.elementAt(i));
            }
        }
    }

    /**
     * Removes entries from the key table.
     * @param service the service <code>PrincipalName</code>.
     * @param etype the etype to match, remove all if -1
     * @param kvno what kvno to remove, -1 for all, -2 for old
     * @return the number of entries deleted
     */
    public int deleteEntries(PrincipalName service, int etype, int kvno) {
        int count = 0;

        // Remember the highest KVNO for each etype. Used for kvno == -2
        Map<Integer,Integer> highest = new HashMap<>();

        for (int i = entries.size()-1; i >= 0; i--) {
            KeyTabEntry e = entries.get(i);
            if (service.match(e.getService())) {
                if (etype == -1 || e.keyType == etype) {
                    if (kvno == -2) {
                        // Two rounds for kvno == -2. In the first round (here),
                        // only find out highest KVNO for each etype
                        if (highest.containsKey(e.keyType)) {
                            int n = highest.get(e.keyType);
                            if (e.keyVersion > n) {
                                highest.put(e.keyType, e.keyVersion);
                            }
                        } else {
                            highest.put(e.keyType, e.keyVersion);
                        }
                    } else if (kvno == -1 || e.keyVersion == kvno) {
                        entries.removeElementAt(i);
                        count++;
                    }
                }
            }
        }

        // Second round for kvno == -2, remove old entries
        if (kvno == -2) {
            for (int i = entries.size()-1; i >= 0; i--) {
                KeyTabEntry e = entries.get(i);
                if (service.match(e.getService())) {
                    if (etype == -1 || e.keyType == etype) {
                        int n = highest.get(e.keyType);
                        if (e.keyVersion != n) {
                            entries.removeElementAt(i);
                            count++;
                        }
                    }
                }
            }
        }
        return count;
    }

    /**
     * Creates key table file version.
     * @param file the key table file.
     * @exception IOException
     */
    public synchronized void createVersion(File file) throws IOException {
        try (KeyTabOutputStream kos =
                new KeyTabOutputStream(new FileOutputStream(file))) {
            kos.write16(KRB5_KT_VNO);
        }
    }
}
