OfficeEncryptionHandler.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.nio.channels.FileChannel;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.Set;
import com.healthmarketscience.jackcess.crypt.AgileEncryptionConfig;
import com.healthmarketscience.jackcess.crypt.CryptCodecProvider;
import com.healthmarketscience.jackcess.crypt.impl.office.AgileEncryptionWriter;
import com.healthmarketscience.jackcess.impl.DatabaseImpl;
import com.healthmarketscience.jackcess.impl.JetFormat;
import com.healthmarketscience.jackcess.impl.PageChannel;
/**
* Removes office encryption from an {@code .accdb} database.
* <p>
* The decryption itself is the read path, so this works for every scheme
* {@link CryptCodecProvider} can open, agile and the older ones alike. What
* this adds is page 0 and the SIDs.
* <p>
* Page 0 is never encrypted and carries three things which have to go: the
* encoding key, which office uses as a per file salt, the password field,
* which holds the low byte of that key repeated, and the {@code
* EncryptionInfo} structure. The format version is left as it is, because ms
* access raises it when it writes agile and does not lower it again when the
* encryption is removed.
* <p>
* Clearing the password field moves the key which masks every SID, so the
* SIDs follow, see {@link SidRemasker}. Leaving that field alone to avoid
* the work is not an option: ms access reads it as a database password and
* prompts for one.
*
* @author James Ahlborn
*/
public class OfficeEncryptionHandler
{
/**
* The page 0 region the EncryptionInfo structure lives in. The structure
* carries its own length and is not the same size in every scheme, 911
* bytes for the agile profile ms access writes by default and 1055 for the
* stronger one, so the region is bounded by what follows it rather than by
* any one structure. Every database measured holds zeroes from the end of
* the structure up to the counters at the end offset.
*/
private static final int ENCRYPTION_INFO_OFFSET =
OfficeCryptCodecHandler.CRYPT_STRUCTURE_OFFSET;
private static final int ENCRYPTION_INFO_END = 0xE00;
/** the version agile encryption declares, and the value which follows it */
private static final short AGILE_VERSION_MAJOR = 4;
private static final short AGILE_VERSION_MINOR = 4;
private static final int AGILE_RESERVED = 0x40;
/** agile needs the access 2010 engine, so the file says so */
private static final int OFFSET_VERSION = 0x14;
private static final byte CODE_VERSION_2010 = 0x3;
private static final byte SUB_VERSION_2010 = 0x1;
/**
* The formats which carry office encryption. An {@code .mdb} holds a
* password and an encoding key at the same page 0 offsets, so a check on
* those offsets alone accepts a database this overwrites rather than
* encrypts.
*/
private static final Set<JetFormat> SUPPORTED_FORMATS = Set.of(
JetFormat.VERSION_12, JetFormat.VERSION_14, JetFormat.VERSION_16,
JetFormat.VERSION_17);
private OfficeEncryptionHandler() {}
/**
* Copies the given encrypted database to the given destination, decrypted.
*/
public static void removeEncryption(Path srcFile, Path dstFile,
String password)
throws IOException
{
int oldSidKey = writeDecrypted(srcFile, dstFile, password);
remaskSids(dstFile, oldSidKey);
}
/**
* Copies the given plain database to the given destination, encrypted with
* agile encryption under the given password.
*/
public static void addEncryption(Path srcFile, Path dstFile,
String password,
AgileEncryptionConfig config)
throws IOException
{
if((password == null) || password.isEmpty()) {
throw new IllegalArgumentException("Password may not be empty");
}
if(config == null) {
throw new IllegalArgumentException("Encryption config is required");
}
// page 0 is never encrypted, so the state is readable before the database
// is opened. Without this, opening an encrypted database with no codec
// provider fails first and reports a missing codec rather than the real
// problem
requirePlain(srcFile);
int oldSidKey = writeEncrypted(srcFile, dstFile, password, config);
remaskSids(dstFile, oldSidKey, password);
}
private static int writeEncrypted(Path srcFile, Path dstFile,
String password,
AgileEncryptionConfig config)
throws IOException
{
DatabaseImpl db = DatabaseImpl.open(srcFile, true, null, false, null,
null, null, false);
try {
PageChannel pageChannel = db.getPageChannel();
JetFormat format = pageChannel.getFormat();
validateFormat(format);
int pageSize = format.PAGE_SIZE;
int pageCount = DbFileRewriter.countPages(srcFile, pageSize);
ByteBuffer page = PageChannel.createBuffer(pageSize);
pageChannel.readPage(page, 0);
if(!isPlain(page, format)) {
throw new IllegalStateException(
"Database is already encrypted, cannot add encryption");
}
int oldSidKey = SidRemasker.createSidKey(page, format);
AgileEncryptionWriter writer =
new AgileEncryptionWriter(config, password);
int encodingKey = BaseCryptCodecHandler.createEncodingKey();
try(FileChannel dst = FileChannel.open(dstFile,
StandardOpenOption.WRITE)) {
fillHeaderPage(page, format, encodingKey, writer);
BaseCryptCodecHandler.applyHeaderMask(page, format);
DbFileRewriter.writePage(dst, page, 0, pageSize);
PageEncrypter encrypter = new PageEncrypter(config, writer,
encodingKey);
for(int pageNumber = 1; pageNumber < pageCount; ++pageNumber) {
page.clear();
pageChannel.readPage(page, pageNumber);
encrypter.encrypt(page.array(), pageNumber);
DbFileRewriter.writePage(dst, page, pageNumber, pageSize);
}
}
return oldSidKey;
} finally {
db.close();
}
}
/**
* Writes the encryption state into the given demasked page 0.
*/
private static void fillHeaderPage(ByteBuffer page, JetFormat format,
int encodingKey,
AgileEncryptionWriter writer)
{
page.putInt(format.OFFSET_ENCODING_KEY, encodingKey);
// ms access fills the password field with the low byte of the encoding
// key, repeated, under the creation date mask
byte low = (byte)(encodingKey & 0xFF);
byte[] pwdMask = DatabaseImpl.getPasswordMask(page, format);
for(int i = 0; i < format.SIZE_PASSWORD; ++i) {
byte b = low;
if(pwdMask != null) {
b ^= pwdMask[i % pwdMask.length];
}
page.put(format.OFFSET_PASSWORD + i, b);
}
byte[] xml = writer.createDescriptor();
int len = (2 + 2 + 4) + xml.length;
if((ENCRYPTION_INFO_OFFSET + 2 + len) > ENCRYPTION_INFO_END) {
throw new IllegalStateException(
"Encryption descriptor is " + len + " bytes, which does not fit");
}
for(int i = ENCRYPTION_INFO_OFFSET; i < ENCRYPTION_INFO_END; ++i) {
page.put(i, (byte)0);
}
page.putShort(ENCRYPTION_INFO_OFFSET, (short)len);
page.putShort(ENCRYPTION_INFO_OFFSET + 2, AGILE_VERSION_MAJOR);
page.putShort(ENCRYPTION_INFO_OFFSET + 4, AGILE_VERSION_MINOR);
page.putInt(ENCRYPTION_INFO_OFFSET + 6, AGILE_RESERVED);
for(int i = 0; i < xml.length; ++i) {
page.put(ENCRYPTION_INFO_OFFSET + 10 + i, xml[i]);
}
// agile needs the 2010 engine, so the version is raised to match. A
// database which already says something later keeps it
if((page.get(OFFSET_VERSION) & 0xFF) < CODE_VERSION_2010) {
page.put(OFFSET_VERSION, CODE_VERSION_2010);
page.put(OFFSET_VERSION + 1, SUB_VERSION_2010);
}
}
/**
* Writes the decrypted database and returns the SID key the source used,
* which the destination no longer describes.
*/
private static int writeDecrypted(Path srcFile, Path dstFile,
String password)
throws IOException
{
DatabaseImpl db = DatabaseImpl.open(
srcFile, true, null, false, null, null,
new CryptCodecProvider(password), false);
try {
PageChannel pageChannel = db.getPageChannel();
JetFormat format = pageChannel.getFormat();
validateFormat(format);
int pageSize = format.PAGE_SIZE;
int pageCount = DbFileRewriter.countPages(srcFile, pageSize);
ByteBuffer page = PageChannel.createBuffer(pageSize);
// readPage removes the header mask from page 0, so the key folds out of
// it as it stands
pageChannel.readPage(page, 0);
int oldSidKey = SidRemasker.createSidKey(page, format);
if(isPlain(page, format)) {
throw new IllegalStateException(
"Database is not encrypted, cannot remove encryption");
}
try(FileChannel dst = FileChannel.open(dstFile,
StandardOpenOption.WRITE)) {
clearHeaderPage(page, format);
BaseCryptCodecHandler.applyHeaderMask(page, format);
DbFileRewriter.writePage(dst, page, 0, pageSize);
for(int pageNumber = 1; pageNumber < pageCount; ++pageNumber) {
page.clear();
pageChannel.readPage(page, pageNumber);
DbFileRewriter.writePage(dst, page, pageNumber, pageSize);
}
}
return oldSidKey;
} finally {
db.close();
}
}
/**
* Converts every SID to the key the decrypted database now describes. This
* runs on the destination, once its pages are readable, and never touches
* the source.
*/
private static void remaskSids(Path dstFile, int oldSidKey)
throws IOException
{
remaskSids(dstFile, oldSidKey, null);
}
private static void remaskSids(Path dstFile, int oldSidKey, String password)
throws IOException
{
DatabaseImpl db = DatabaseImpl.open(
dstFile, false, null, false, null, null,
(password == null) ? null : new CryptCodecProvider(password), false);
try {
PageChannel pageChannel = db.getPageChannel();
ByteBuffer headerPage = BaseCryptCodecHandler.readHeaderPage(pageChannel);
int newSidKey = SidRemasker.createSidKey(headerPage,
pageChannel.getFormat());
pageChannel.startWrite();
try {
SidRemasker.remaskSids(db, oldSidKey, newSidKey);
} finally {
pageChannel.finishWrite();
}
db.flush();
} finally {
db.close();
}
}
/**
* Fails unless the given database is plain, reading page 0 straight from
* the file.
*/
private static void requirePlain(Path dbFile) throws IOException
{
try(FileChannel channel = FileChannel.open(dbFile,
StandardOpenOption.READ)) {
JetFormat format = JetFormat.getFormat(channel);
validateFormat(format);
ByteBuffer page = PageChannel.createBuffer(format.PAGE_SIZE);
DbFileRewriter.readPage(channel, page, 0, format.PAGE_SIZE);
BaseCryptCodecHandler.applyHeaderMask(page, format);
if(!isPlain(page, format)) {
throw new IllegalStateException(
"Database is already encrypted, cannot add encryption");
}
}
}
private static void validateFormat(JetFormat format)
{
if(!SUPPORTED_FORMATS.contains(format)) {
throw new UnsupportedOperationException(
"Office encryption is only supported for accdb databases, not " +
format);
}
}
/** Returns whether the given demasked page 0 describes a plain database. */
private static boolean isPlain(ByteBuffer page, JetFormat format)
{
return (page.getInt(format.OFFSET_ENCODING_KEY) == 0) &&
(page.getShort(ENCRYPTION_INFO_OFFSET) == 0);
}
/**
* Removes the encryption state from the given demasked page 0.
*/
private static void clearHeaderPage(ByteBuffer page, JetFormat format)
{
page.putInt(format.OFFSET_ENCODING_KEY, 0);
// the password field carries a second mask built from the creation date,
// so an empty field is that mask rather than zeroes. Writing zeroes here
// leaves a field which reads back as a password
byte[] pwdMask = DatabaseImpl.getPasswordMask(page, format);
for(int i = 0; i < format.SIZE_PASSWORD; ++i) {
page.put(format.OFFSET_PASSWORD + i,
(pwdMask == null) ? 0 : pwdMask[i % pwdMask.length]);
}
for(int i = ENCRYPTION_INFO_OFFSET; i < ENCRYPTION_INFO_END; ++i) {
page.put(i, (byte)0);
}
}
/**
* Encrypts pages under the content key. Each page gets its own IV, built
* from the key data salt and the encoding key xored with the page number,
* which is why encryption moves no data: the page number is the only per
* page input.
*/
private static final class PageEncrypter
{
private final AgileEncryptionConfig _config;
private final byte[] _keyValue;
private final byte[] _salt;
private final byte[] _encodingKey = new byte[4];
private PageEncrypter(AgileEncryptionConfig config,
AgileEncryptionWriter writer, int encodingKey) {
_config = config;
_keyValue = writer.getKeyValue();
_salt = writer.getKeyDataSalt();
PageChannel.wrap(_encodingKey).putInt(encodingKey);
}
private void encrypt(byte[] pageBytes, int pageNumber) {
byte[] blockBytes = BaseCryptCodecHandler.applyPageNumber(
_encodingKey, 0, pageNumber);
AgileEncryptionWriter.encryptPage(_config, _keyValue, _salt, blockBytes,
pageBytes);
}
}
}