SidRemasker.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.impl;


import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

import com.healthmarketscience.jackcess.Column;
import com.healthmarketscience.jackcess.Row;
import com.healthmarketscience.jackcess.Table;
import com.healthmarketscience.jackcess.impl.ByteUtil;
import com.healthmarketscience.jackcess.impl.DatabaseImpl;
import com.healthmarketscience.jackcess.impl.JetFormat;
import com.healthmarketscience.jackcess.impl.TableImpl;
import com.healthmarketscience.jackcess.impl.PageChannel;
import org.bouncycastle.crypto.StreamCipher;
import org.bouncycastle.crypto.engines.RC4Engine;
import org.bouncycastle.crypto.params.KeyParameter;


/**
 * Masks and re-masks the SIDs a database stores.
 * <p>
 * Every SID is held as its plaintext xored with an rc4 keystream, under a key
 * folded out of page 0.  The fold reads the creation date and the password
 * field, so anything which writes the password field moves the key and leaves
 * every stored SID describing the key the database used to have.  Ms access
 * then prompts for a password and refuses to open the database, or opens it
 * showing no tables, because no entry in {@code MSysACEs} resolves to the
 * current user.
 * <p>
 * Two things write that field: setting the jet database password, and adding
 * or removing office encryption, which fills it with the low byte of the
 * encoding key.
 * <p>
 * The plaintext SIDs never have to be known.  Xoring out the old keystream
 * and in the new one converts a stored value directly, so this works for a
 * database created under any workgroup file.
 *
 * @author James Ahlborn
 */
public class SidRemasker
{
  /** the SID columns which have to be re-masked, table name to column name */
  private static final Map<String,String> SID_COLUMNS = new LinkedHashMap<>();
  static {
    SID_COLUMNS.put("MSysACEs", "SID");
    SID_COLUMNS.put("MSysObjects", "Owner");
  }

  /** the fold which builds the SID key wraps its shift at this bit */
  private static final int SID_KEY_SHIFT_MODULUS = 24;

  /** the SID key is used as this many little-endian bytes of rc4 key */
  private static final int SID_KEY_LENGTH = 4;

  private SidRemasker() {}

  /**
   * Folds the 32-bit key which masks every SID in the database out of the
   * given header page.  The fold walks the even bytes of the password region,
   * which are the low bytes of the utf-16 password, over a value seeded from
   * the database creation date.  The region runs past the password field and
   * takes in the creation date a second time, which is why the key changes
   * with the creation date even when there is no password at all.
   */
  public static int createSidKey(ByteBuffer headerPage, JetFormat format)
  {
    byte[] region = BaseCryptCodecHandler.readPasswordRegion(headerPage,
                                                             format);

    int key = ByteUtil.getInt(headerPage, format.OFFSET_HEADER_DATE,
                              PageChannel.DEFAULT_BYTE_ORDER);

    for(int i = 0; i < format.SIZE_PASSWORD; ++i) {
      key ^= (region[i * 2] & 0xFF) << (i % SID_KEY_SHIFT_MODULUS);
    }

    return key;
  }

  /**
   * Converts every SID in the given database from the old key to the new one.
   * <p>
   * Every row of both columns is converted.  That is right for a password
   * change, where ms access re-masks everything, and it is more than ms
   * access does when it encrypts an {@code .accdb}: there it re-masks all of
   * {@code MSysACEs} but only the {@code MSysObjects.Owner} rows it happens
   * to rewrite, and the rows it skips keep describing the key the database
   * had before.
   * <p>
   * Converting those skipped rows a second time leaves them meaningless.
   * Which rows ms access skips cannot be worked out from the database:
   * {@code MSysACEs} does not use every SID {@code MSysObjects} does, so
   * unmasking is not enough to tell a skipped row from a converted one.  Ms
   * access opens a database whose {@code Owner} values are all converted, and
   * reads {@code MSysACEs} rather than {@code Owner} when it decides what the
   * current user may see, so converting everything is safe.
   */
  public static void remaskSids(DatabaseImpl db, int oldKey, int newKey)
    throws IOException
  {
    if(oldKey == newKey) {
      return;
    }

    RemaskPad pad = new RemaskPad(oldKey, newKey);

    for(Map.Entry<String,String> sidCol : SID_COLUMNS.entrySet()) {

      TableImpl table = db.getSystemTable(sidCol.getKey());
      if(table == null) {
        continue;
      }

      Column column = findColumn(table, sidCol.getValue());
      if(column == null) {
        continue;
      }
      String colName = column.getName();

      // read the rows before modifying any of them
      List<Row> rows = new ArrayList<>();
      for(Row row : table) {
        rows.add(row);
      }

      for(Row row : rows) {
        byte[] sid = (byte[])row.get(colName);
        if((sid == null) || (sid.length == 0)) {
          continue;
        }
        pad.apply(sid);

        // only the SID column is written.  Writing the whole row would write
        // every column, and MSysObjects carries seven long value columns
        // whose contents would land on freshly allocated pages, growing the
        // database and orphaning what they replace
        table.updateValue(column, row.getId(), sid);
      }
    }
  }

  private static Column findColumn(Table table, String colName)
  {
    for(Column column : table.getColumns()) {
      if(colName.equalsIgnoreCase(column.getName())) {
        return column;
      }
    }
    return null;
  }

  /**
   * Returns the first len bytes of the rc4 keystream for the given key.
   */
  private static byte[] createKeystream(int key, int len)
  {
    byte[] keyBytes = new byte[SID_KEY_LENGTH];
    PageChannel.wrap(keyBytes).putInt(key);

    StreamCipher cipher = new RC4Engine();
    cipher.init(BaseCryptCodecHandler.CIPHER_DECRYPT_MODE,
                new KeyParameter(keyBytes));

    // running the cipher over zeroes yields the keystream itself
    byte[] keystream = new byte[len];
    cipher.processBytes(new byte[len], 0, len, keystream, 0);
    return keystream;
  }

  /**
   * The xor pad which converts a SID masked with the old key into the same SID
   * masked with the new one.  It is the xor of the two keystreams, both of
   * which start at the beginning for every SID regardless of length, so
   * applying it never materializes a plaintext SID.
   */
  private static final class RemaskPad
  {
    private final int _oldKey;
    private final int _newKey;
    private byte[] _pad = new byte[0];

    private RemaskPad(int oldKey, int newKey) {
      _oldKey = oldKey;
      _newKey = newKey;
    }

    private void apply(byte[] sid) {
      ensureLength(sid.length);
      for(int i = 0; i < sid.length; ++i) {
        sid[i] ^= _pad[i];
      }
    }

    private void ensureLength(int len) {
      if(_pad.length >= len) {
        return;
      }

      byte[] pad = createKeystream(_oldKey, len);
      byte[] newKeystream = createKeystream(_newKey, len);
      for(int i = 0; i < len; ++i) {
        pad[i] ^= newKeystream[i];
      }
      _pad = pad;
    }
  }
}