PasswordUtil.java

/*
Copyright (c) 2026 James Ahlborn

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package com.healthmarketscience.jackcess.crypt;


import java.io.IOException;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;

import com.healthmarketscience.jackcess.Database;
import com.healthmarketscience.jackcess.DatabaseBuilder;
import com.healthmarketscience.jackcess.crypt.impl.DbFileRewriter;
import com.healthmarketscience.jackcess.crypt.impl.JetPasswordHandler;
import com.healthmarketscience.jackcess.impl.JetFormat;


/**
 * Adds, changes and removes the database password on a database.  The current
 * password can be read with {@link Database#getDatabasePassword}, which needs
 * nothing from this library.
 * <p>
 * Only jet 4 databases (mdb files written by access 2000 and 2003) are
 * supported, and the other formats throw rather than write something ms
 * access cannot open:
 * <ul>
 * <li>jet 3 stores the password field without the creation date mask, and
 *     has not been verified</li>
 * <li>in an accdb, a database password means the file is encrypted, which is
 *     a different mechanism entirely (see {@link CryptCodecProvider})</li>
 * <li>msisam (money) files feed the password into the page encoding key</li>
 * </ul>
 * <p>
 * Setting a password changes the page 0 header and rewrites every SID in the
 * database, and the two have to agree or ms access prompts for the password
 * and then refuses to open the file.  An interruption part way through leaves
 * the database in exactly that state, and re-running the operation does not
 * repair it, because the key is derived from the header the run starts with.
 * <p>
 * <b>The forms which take a {@link Path} are safe from that.</b>  They build
 * a separate file and replace the original only once it is complete, so an
 * interrupted operation leaves the original database as it was.  The in place
 * form gives the database a new identity on disk, which drops any hard link
 * to it.  <b>The forms which take an open {@link Database} are not</b>,
 * because the caller holds the file, so copy it first.
 * <p>
 * The database must not be in use, and an open one must be open for
 * writing.
 *
 * @author James Ahlborn
 */
public class PasswordUtil
{
  private PasswordUtil() {}

  /**
   * Sets the database password on the given database, replacing any existing
   * one.
   *
   * @param db the database to modify, open for writing
   * @param password the new password, which may not be {@code null} or empty
   */
  public static void setDatabasePassword(Database db, String password)
    throws IOException
  {
    requirePassword(password);

    new JetPasswordHandler(db).setPassword(password);
  }

  /**
   * Sets the database password on the given database, replacing the file with
   * the result.
   *
   * @param dbFile the database to modify, not in use
   * @param password the new password, which may not be {@code null} or empty
   */
  public static void setDatabasePassword(Path dbFile, String password)
    throws IOException
  {
    requirePassword(password);

    DbFileRewriter.rewriteInPlace(
        dbFile, (src, dst) -> rewritePassword(src, dst, password));
  }

  /**
   * Writes a copy of the given database with the given password, leaving the
   * source alone.
   *
   * @param srcFile the database to copy, not in use
   * @param dstFile the file to write, overwritten if it exists
   * @param password the new password, which may not be {@code null} or empty
   */
  public static void setDatabasePassword(Path srcFile, Path dstFile,
                                         String password)
    throws IOException
  {
    requirePassword(password);

    DbFileRewriter.rewriteTo(
        srcFile, dstFile, (src, dst) -> rewritePassword(src, dst, password));
  }

  /**
   * Removes the database password from the given database.  Does nothing if
   * it does not have one.
   *
   * @param db the database to modify, open for writing
   */
  public static void removeDatabasePassword(Database db)
    throws IOException
  {
    new JetPasswordHandler(db).setPassword(null);
  }

  /**
   * Removes the database password from the given database, replacing the file
   * with the result.  Does nothing to the password if it does not have one.
   *
   * @param dbFile the database to modify, not in use
   */
  public static void removeDatabasePassword(Path dbFile) throws IOException
  {
    DbFileRewriter.rewriteInPlace(
        dbFile, (src, dst) -> rewritePassword(src, dst, null));
  }

  /**
   * Writes a copy of the given database with no password, leaving the source
   * alone.
   *
   * @param srcFile the database to copy, not in use
   * @param dstFile the file to write, overwritten if it exists
   */
  public static void removeDatabasePassword(Path srcFile, Path dstFile)
    throws IOException
  {
    DbFileRewriter.rewriteTo(
        srcFile, dstFile, (src, dst) -> rewritePassword(src, dst, null));
  }

  private static void requireJet4(Path dbFile) throws IOException
  {
    try(FileChannel channel = FileChannel.open(dbFile,
                                               StandardOpenOption.READ)) {
      JetPasswordHandler.validateFormat(JetFormat.getFormat(channel));
    }
  }

  private static void requirePassword(String password)
  {
    if((password == null) || password.isEmpty()) {
      throw new IllegalArgumentException(
          "Password may not be empty, use removeDatabasePassword instead");
    }
  }

  /**
   * Copies the database and changes the password in the copy.  The change
   * touches page 0 and the SID of every row which has one, which is little of
   * the file, so the copy is a plain copy and the work happens through the
   * database itself.
   */
  private static void rewritePassword(Path srcFile, Path dstFile,
                                      String password)
    throws IOException
  {
    // the format is read from the file rather than from an open database,
    // because a format this cannot write may also need a codec to open at
    // all, which would report a missing codec instead of the real problem
    requireJet4(srcFile);

    Files.copy(srcFile, dstFile, StandardCopyOption.REPLACE_EXISTING);

    try(Database db = new DatabaseBuilder(dstFile).open()) {
      new JetPasswordHandler(db).setPassword(password);
    }
  }
}