JetPasswordHandler.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.Arrays;
import com.healthmarketscience.jackcess.Database;
import com.healthmarketscience.jackcess.impl.ByteUtil;
import com.healthmarketscience.jackcess.impl.DatabaseImpl;
import com.healthmarketscience.jackcess.impl.JetFormat;
import com.healthmarketscience.jackcess.impl.PageChannel;
/**
* Adds, changes and removes the database password on a jet 4 database.
* <p>
* Two pieces of state have to agree. The password field in page 0 is what
* makes ms access prompt for a password, and every SID stored in the database
* is masked with an rc4 keystream whose key is folded out of page 0, the
* password field included. Writing only the password field produces a
* database which ms access prompts for and then refuses to open, reporting
* that the user does not have permission.
* <p>
* So changing the password means re-masking every SID under the new key,
* which {@link SidRemasker} does.
*
* @author James Ahlborn
*/
public class JetPasswordHandler
{
private final DatabaseImpl _db;
private final PageChannel _pageChannel;
private final JetFormat _format;
public JetPasswordHandler(Database db) {
_db = (DatabaseImpl)db;
_pageChannel = _db.getPageChannel();
_format = _pageChannel.getFormat();
}
/**
* Sets the database password, or removes it if the given password is
* {@code null} or empty.
*/
public void setPassword(String password) throws IOException
{
validatePasswordChange();
// encode the password up front, so an unusable one is rejected before
// anything is read or written
byte[] newField = createPasswordField(password);
// make sure nothing is still pending behind the changes below
_db.flush();
ByteBuffer headerPage = BaseCryptCodecHandler.readHeaderPage(_pageChannel);
byte[] oldField = readPasswordField(headerPage);
int oldKey = SidRemasker.createSidKey(headerPage, _format);
writePasswordField(headerPage, newField);
if(Arrays.equals(oldField, readPasswordField(headerPage))) {
// the database already has this password, nothing to do
return;
}
int newKey = SidRemasker.createSidKey(headerPage, _format);
_pageChannel.startWrite();
try {
// the SIDs are rewritten before page 0 so that a failure part way
// through leaves the password field describing the key the database
// started with. note that this operation is not atomic either way, see
// the PasswordUtil documentation
SidRemasker.remaskSids(_db, oldKey, newKey);
_pageChannel.writePage(headerPage, 0);
} finally {
_pageChannel.finishWrite();
}
_db.flush();
}
private void validatePasswordChange()
{
if(_db.isReadOnly()) {
throw new IllegalStateException(
"Database is read-only, cannot modify the database password");
}
validateFormat(_format);
}
/**
* Fails unless the given format holds the password field the way this
* writes it.
*/
public static void validateFormat(JetFormat format)
{
if(format != JetFormat.VERSION_4) {
throw new UnsupportedOperationException(
"Modifying the database password is only supported for jet 4 " +
"databases, not " + format);
}
}
/**
* Returns the contents of the password field for the given password, before
* the creation date mask is applied. A {@code null} or empty password
* gives the empty field which indicates no password.
*/
private byte[] createPasswordField(String password)
{
byte[] field = new byte[_format.SIZE_PASSWORD];
if(password != null) {
ByteBuffer encoded = _db.getCharset().encode(password);
int len = encoded.remaining();
if(len > field.length) {
throw new IllegalArgumentException(
"Password encodes to " + len + " bytes, which is longer than " +
"the maximum of " + field.length);
}
encoded.get(field, 0, len);
}
return field;
}
private byte[] readPasswordField(ByteBuffer headerPage)
{
return ByteUtil.getBytes(headerPage, _format.OFFSET_PASSWORD,
_format.SIZE_PASSWORD);
}
/**
* Writes the given password field into the given header page, masked the way
* the database expects it.
*/
private void writePasswordField(ByteBuffer headerPage, byte[] field)
{
// the field carries an additional mask generated from the database
// creation date
byte[] pwdMask = DatabaseImpl.getPasswordMask(headerPage, _format);
if(pwdMask != null) {
field = field.clone();
for(int i = 0; i < field.length; ++i) {
field[i] ^= pwdMask[i % pwdMask.length];
}
}
for(int i = 0; i < field.length; ++i) {
headerPage.put((_format.OFFSET_PASSWORD + i), field[i]);
}
}
}