AgileEncryptionWriter.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.office;


import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;

import com.healthmarketscience.jackcess.crypt.AgileEncryptionConfig;
import com.healthmarketscience.jackcess.crypt.impl.BaseCryptCodecHandler;
import com.healthmarketscience.jackcess.crypt.impl.OfficeCryptCodecHandler;
import org.bouncycastle.crypto.BlockCipher;
import org.bouncycastle.crypto.BufferedBlockCipher;
import org.bouncycastle.crypto.DefaultBufferedBlockCipher;
import org.bouncycastle.crypto.Digest;
import org.bouncycastle.crypto.params.KeyParameter;
import org.bouncycastle.crypto.params.ParametersWithIV;

/**
 * Builds the agile key material and the descriptor which carries it.
 * <p>
 * This is the inverse of {@link AgileEncryptionProvider}, which is what makes
 * it testable without ms access: taking the salts out of a database ms access
 * encrypted, decrypting its three wrapped values and encrypting them again
 * has to give back the bytes ms access wrote.
 * <p>
 * Nothing here writes a {@code dataIntegrity} element.  No {@code .accdb} ms
 * access produced carries one, from 2007 to a current version, and the read
 * path does not look for it.
 *
 * @author James Ahlborn
 */
public class AgileEncryptionWriter
{
  private static final String ENC_NS =
    "http://schemas.microsoft.com/office/2006/encryption";
  private static final String PWD_NS =
    "http://schemas.microsoft.com/office/2006/keyEncryptor/password";

  private static final String XML_PROLOG =
    "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n";

  private static final Base64.Encoder B64_ENC = Base64.getEncoder();

  private static final SecureRandom RANDOM = new SecureRandom();

  private final AgileEncryptionConfig _config;
  private final byte[] _pwdBytes;
  private final byte[] _keyDataSalt;
  private final byte[] _pwdSalt;
  private final byte[] _keyValue;
  private final byte[] _verifierInput;

  /**
   * Generates fresh key material for the given password.
   */
  public AgileEncryptionWriter(AgileEncryptionConfig config, String password)
  {
    this(config, password, randomBytes(config.getSaltSize()),
         randomBytes(config.getSaltSize()),
         randomBytes(BaseCryptCodecHandler.bits2bytes(config.getKeyBits())),
         randomBytes(config.getSaltSize()));
  }

  /**
   * Uses the given key material rather than generating it, which is how the
   * forward direction is checked against a database ms access wrote.
   */
  public AgileEncryptionWriter(AgileEncryptionConfig config, String password,
                               byte[] keyDataSalt, byte[] pwdSalt,
                               byte[] keyValue, byte[] verifierInput)
  {
    _config = config;
    _pwdBytes = OfficeCryptCodecHandler.getPasswordBytes(password);
    _keyDataSalt = keyDataSalt;
    _pwdSalt = pwdSalt;
    _keyValue = keyValue;
    _verifierInput = verifierInput;
  }

  /** Returns the key the pages are encrypted with. */
  public byte[] getKeyValue() {
    return _keyValue;
  }

  /** Returns the salt which also serves as the page cipher IV base. */
  public byte[] getKeyDataSalt() {
    return _keyDataSalt;
  }

  public byte[] encryptVerifierHashInput() {
    return wrap(AgileEncryptionProvider.VERIFIER_INPUT_BLOCK, _verifierInput);
  }

  public byte[] encryptVerifierHashValue() {
    byte[] hashed = AgileEncryptionProvider.padToBlockSize(
        BaseCryptCodecHandler.hash(digest(), _verifierInput),
        _config.getBlockSize());
    return wrap(AgileEncryptionProvider.VERIFIER_VALUE_BLOCK, hashed);
  }

  /** Reverses {@link #encryptKeyValue}, which is how the pair is checked. */
  public byte[] unwrapKeyValue(byte[] wrapped) {
    return unwrap(AgileEncryptionProvider.KEY_VALUE_BLOCK, wrapped);
  }

  /** Reverses {@link #encryptVerifierHashInput}. */
  public byte[] unwrapVerifierHashInput(byte[] wrapped) {
    return unwrap(AgileEncryptionProvider.VERIFIER_INPUT_BLOCK, wrapped);
  }

  private byte[] unwrap(byte[] blockBytes, byte[] wrapped) {
    return crypt(blockBytes, wrapped, false);
  }

  public byte[] encryptKeyValue() {
    return wrap(AgileEncryptionProvider.KEY_VALUE_BLOCK, _keyValue);
  }

  /**
   * Returns the descriptor xml, which is what page 0 carries after the
   * version and the reserved value.
   */
  public byte[] createDescriptor()
  {
    Map<String,Object> keyDataAttrs = algorithmAttrs();
    keyDataAttrs.put("saltValue", _keyDataSalt);

    Map<String,Object> encryptedKeyAttrs =
      attrs("spinCount", _config.getSpinCount());
    encryptedKeyAttrs.putAll(algorithmAttrs());
    encryptedKeyAttrs.put("saltValue", _pwdSalt);
    encryptedKeyAttrs.put("encryptedVerifierHashInput",
                          encryptVerifierHashInput());
    encryptedKeyAttrs.put("encryptedVerifierHashValue",
                          encryptVerifierHashValue());
    encryptedKeyAttrs.put("encryptedKeyValue", encryptKeyValue());

    StringBuilder xml = new StringBuilder(XML_PROLOG);
    startElement(xml, "encryption", attrs("xmlns", ENC_NS,
                                          "xmlns:p", PWD_NS));
    emptyElement(xml, "keyData", keyDataAttrs);
    startElement(xml, "keyEncryptors", Map.of());
    startElement(xml, "keyEncryptor", attrs("uri", PWD_NS));
    emptyElement(xml, "p:encryptedKey", encryptedKeyAttrs);
    endElement(xml, "keyEncryptor");
    endElement(xml, "keyEncryptors");
    endElement(xml, "encryption");

    return xml.toString().getBytes(StandardCharsets.UTF_8);
  }

  /**
   * Returns the given name and value pairs as attributes.  The map keeps the
   * order they are given in, which is the order ms access writes them in.
   */
  private static Map<String,Object> attrs(Object... nameValues)
  {
    Map<String,Object> attrs = new LinkedHashMap<>();
    for(int i = 0; i < nameValues.length; i += 2) {
      attrs.put((String)nameValues[i], nameValues[i + 1]);
    }
    return attrs;
  }

  /**
   * Returns the algorithms, which {@code keyData} and {@code encryptedKey}
   * both carry and which have to agree.
   */
  private Map<String,Object> algorithmAttrs()
  {
    return attrs("saltSize", _config.getSaltSize(),
                 "blockSize", _config.getBlockSize(),
                 "keyBits", _config.getKeyBits(),
                 "hashSize", _config.getHashSize(),
                 "cipherAlgorithm", _config.getCipher().getXmlName(),
                 "cipherChaining", _config.getChaining().getXmlName(),
                 "hashAlgorithm", _config.getHash().getXmlName());
  }

  /** Appends a start tag, which content and an end tag follow. */
  private static void startElement(StringBuilder buf, String name,
                                   Map<String,Object> attrs)
  {
    appendElement(buf, name, attrs, false);
  }

  /** Appends an element which holds nothing except its attributes. */
  private static void emptyElement(StringBuilder buf, String name,
                                   Map<String,Object> attrs)
  {
    appendElement(buf, name, attrs, true);
  }

  private static void endElement(StringBuilder buf, String name)
  {
    buf.append("</").append(name).append('>');
  }

  /**
   * Appends one element.  No value is escaped, because every value written
   * here is a decimal number, base64 text, one of the algorithm names the
   * config holds or one of the two namespace uris, and none of those can
   * contain a quote, an ampersand or an angle bracket.  The password is not
   * among them, and reaches the descriptor only as the ciphertext it wraps.
   */
  private static void appendElement(StringBuilder buf, String name,
                                    Map<String,Object> attrs, boolean empty)
  {
    buf.append('<').append(name);
    for(Map.Entry<String,Object> attr : attrs.entrySet()) {
      buf.append(' ').append(attr.getKey()).append("=\"")
        .append(attrValue(attr.getValue())).append('"');
    }
    buf.append(empty ? "/>" : ">");
  }

  /** Renders an attribute, which for a byte array is base64. */
  private static Object attrValue(Object value)
  {
    return (value instanceof byte[]) ?
      B64_ENC.encodeToString((byte[])value) : value;
  }

  /**
   * Encrypts one page under the content key.  The IV comes from the key data
   * salt and the block bytes, which are the encoding key xored with the page
   * number, so every page gets its own IV without moving any data.
   */
  public static void encryptPage(AgileEncryptionConfig config,
                                 byte[] keyValue, byte[] salt,
                                 byte[] blockBytes, byte[] pageBytes)
  {
    Digest digest = XmlEncryptionDescriptor.initDigest(
        config.getHash().getXmlName());
    byte[] iv = AgileEncryptionProvider.deriveIV(digest, salt, blockBytes,
                                         config.getBlockSize());

    BufferedBlockCipher cipher = BaseCryptCodecHandler.encryptInit(
        new DefaultBufferedBlockCipher(
            XmlEncryptionDescriptor.initCipher(
                config.getCipher().getXmlName(),
                config.getChaining().getXmlName())),
        new ParametersWithIV(new KeyParameter(keyValue), iv));

    byte[] out = BaseCryptCodecHandler.processBytesFully(cipher, pageBytes);
    System.arraycopy(out, 0, pageBytes, 0, pageBytes.length);
  }

  /**
   * Encrypts one of the three values under a key derived for its block
   * constant, with the password salt as the IV.
   */
  private byte[] wrap(byte[] blockBytes, byte[] value)
  {
    return crypt(blockBytes, value, true);
  }

  private byte[] crypt(byte[] blockBytes, byte[] value, boolean encrypt)
  {
    // the password salt is the IV, fixed to the block size the way every
    // other agile IV is
    ParametersWithIV params = new ParametersWithIV(
        new KeyParameter(deriveKey(blockBytes)),
        AgileEncryptionProvider.deriveIV(digest(), _pwdSalt, null,
                                         _config.getBlockSize()));
    BufferedBlockCipher cipher = new DefaultBufferedBlockCipher(newCipher());
    cipher = (encrypt ? BaseCryptCodecHandler.encryptInit(cipher, params) :
              BaseCryptCodecHandler.decryptInit(cipher, params));

    // every value is a whole number of blocks, so nothing is padded and the
    // output is the same length as the input
    return BaseCryptCodecHandler.processBytesFully(cipher, value);
  }

  /** Builds the key which wraps one value. */
  private byte[] deriveKey(byte[] blockBytes)
  {
    return AgileEncryptionProvider.deriveKey(
        digest(), _pwdBytes, _pwdSalt, _config.getSpinCount(), blockBytes,
        BaseCryptCodecHandler.bits2bytes(_config.getKeyBits()));
  }

  private Digest digest() {
    return XmlEncryptionDescriptor.initDigest(_config.getHash().getXmlName());
  }

  private BlockCipher newCipher() {
    return XmlEncryptionDescriptor.initCipher(
        _config.getCipher().getXmlName(),
        _config.getChaining().getXmlName());
  }

  private static byte[] randomBytes(int len) {
    byte[] bytes = new byte[len];
    RANDOM.nextBytes(bytes);
    return bytes;
  }
}