JetEncodingHandler.java
/*
Copyright (c) 2025 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 com.healthmarketscience.jackcess.impl.JetFormat;
import com.healthmarketscience.jackcess.impl.PageChannel;
import org.bouncycastle.crypto.StreamCipher;
import org.bouncycastle.crypto.engines.RC4Engine;
import org.bouncycastle.crypto.params.KeyParameter;
/**
* Adds and removes jet encoding, which is the scheme ms access called
* "Encode/Decode Database" and removed after access 2003.
* <p>
* Every page above page 0 is run through rc4 under the encoding key xored
* with the page number. Page 0 is never encoded and holds the key in the
* clear at {@link JetFormat#OFFSET_ENCODING_KEY}, so the scheme keeps nothing
* secret and stops only a reader which does not know about it. The transform
* is its own inverse, so one routine does both directions.
* <p>
* Only jet 4 is supported. Altering the encoding rewrites the database, and
* jackcess does not write jet 3, so an encoded jet 3 database can be read
* through {@link com.healthmarketscience.jackcess.crypt.CryptCodecProvider}
* but not decoded to a file.
* <p>
* Nothing else in the database changes. The SID masking is folded from the
* creation date and the page 0 password field and never reads the encoding
* key, so adding or removing the encoding leaves every SID valid, and a jet
* database password is untouched.
*
* @author James Ahlborn
*/
public class JetEncodingHandler
{
private JetEncodingHandler() {}
/**
* Copies the given database to the given destination, adding jet encoding
* under a freshly generated key.
*/
public static void addEncoding(Path srcFile, Path dstFile) throws IOException
{
recode(srcFile, dstFile, true, BaseCryptCodecHandler.createEncodingKey());
}
/**
* Copies the given database to the given destination, adding jet encoding
* under the given key, which is for reproducing a known file and must not
* be zero.
*/
public static void addEncoding(Path srcFile, Path dstFile, int key)
throws IOException
{
if(key == 0) {
throw new IllegalArgumentException("Encoding key may not be zero");
}
recode(srcFile, dstFile, true, key);
}
/**
* Copies the given database to the given destination, removing its jet
* encoding.
*/
public static void removeEncoding(Path srcFile, Path dstFile)
throws IOException
{
recode(srcFile, dstFile, false, 0);
}
private static void recode(Path srcFile, Path dstFile, boolean add,
int newKey)
throws IOException
{
try(FileChannel src = FileChannel.open(srcFile, StandardOpenOption.READ);
FileChannel dst = FileChannel.open(dstFile, StandardOpenOption.WRITE)) {
JetFormat format = JetFormat.getFormat(src);
validateFormat(format);
int pageSize = format.PAGE_SIZE;
int pageCount = DbFileRewriter.countPages(srcFile, pageSize);
ByteBuffer page = PageChannel.createBuffer(pageSize);
// page 0 carries the key, and is written first so that a reader of the
// destination sees the state its pages are in
DbFileRewriter.readPage(src, page, 0, pageSize);
BaseCryptCodecHandler.applyHeaderMask(page, format);
int key = page.getInt(format.OFFSET_ENCODING_KEY);
if(add) {
if(key != 0) {
throw new IllegalStateException(
"Database is already encoded, cannot add encoding");
}
key = newKey;
} else {
if(key == 0) {
throw new IllegalStateException(
"Database is not encoded, cannot remove encoding");
}
}
page.putInt(format.OFFSET_ENCODING_KEY, (add ? key : 0));
BaseCryptCodecHandler.applyHeaderMask(page, format);
DbFileRewriter.writePage(dst, page, 0, pageSize);
for(int pageNumber = 1; pageNumber < pageCount; ++pageNumber) {
DbFileRewriter.readPage(src, page, pageNumber, pageSize);
cryptPage(page, key, pageNumber);
DbFileRewriter.writePage(dst, page, pageNumber, pageSize);
}
}
}
/**
* Jet 3 is refused in both directions. The transform itself works on jet 3,
* and the read path opens an encoded jet 3 database, but altering one means
* writing a jet 3 file and jackcess does not write jet 3.
*/
private static void validateFormat(JetFormat format)
{
if(format != JetFormat.VERSION_4) {
throw new UnsupportedOperationException(
"Jet encoding is only supported for jet 4 databases, not " + format);
}
}
/**
* Encodes or decodes one page, which are the same operation.
*/
private static void cryptPage(ByteBuffer page, int key, int pageNumber)
{
byte[] pageKey = new byte[4];
PageChannel.wrap(pageKey).putInt(key ^ pageNumber);
StreamCipher cipher = new RC4Engine();
cipher.init(BaseCryptCodecHandler.CIPHER_DECRYPT_MODE,
new KeyParameter(pageKey));
byte[] bytes = page.array();
cipher.processBytes(bytes, 0, bytes.length, bytes, 0);
}
}