Fast patching via IL rewriting of Bitwarden images (#278)

* Fast patching via IL rewriting of Bitwarden images

Brings back the pre-047c4dd approach of patching pre-built Bitwarden
images instead of cloning and building from source. The fast patch mode
(now default) pulls ghcr.io/bitwarden/{api,identity} and rewrites
Core.dll in-place using Mono.Cecil, bypassing the full source build.

Updated to work with current Bitwarden:
- Uses SingleFileExtractor.Core to extract Core.dll from the
  PublishSingleFile bundle before patching; replaces the native
  launcher with a shell script wrapper (exec dotnet /app/Api.dll)
  so entrypoint.sh continues to work unchanged
- LicensingService search is now namespace-agnostic (handles the
  Bit.Core.Services → Bit.Core.Billing.Services rename)
- Thumbprint matching uses Contains() instead of Equals() to handle
  the hidden Unicode LRM character prepended to the production
  thumbprint string literal in the compiled IL

The original source-build path is preserved and accessible via
BITBETTER_BUILD_FROM_SOURCE=1.

Signed-off-by: Lorenzo Moscati <lorenzo@moscati.page>

* Address review: fix correctness and robustness

- dotnet publish -c Release with explicit -o to match Dockerfile expectation
- Add --platform "$TARGETPLATFORM" to fast-patch docker builds for parity with source-build mode
- mkdir -p for idempotent .keys directory creation
- Align namespace to BitwardenSelfLicensor (repo convention)
- Branch bundle extraction on .dll extension instead of bare catch; exit 1 with clear message on failure
- Replace First() with FirstOrDefault() + targeted error on missing licensing resource
- FixRuntimeConfig derives framework name/version from includedFrameworks; switch to LatestPatch rollForward

Signed-off-by: Lorenzo Moscati <lorenzo@moscati.page>

* Add BITBETTER_BUILD_FROM_SOURCE notes to README.md

Signed-off-by: Lorenzo Moscati <lorenzo@moscati.page>

---------

Signed-off-by: Lorenzo Moscati <lorenzo@moscati.page>
Co-authored-by: h44z <christoph.h@sprinternet.at>
This commit is contained in:
Lorenzo Moscati
2026-04-12 12:03:25 +02:00
committed by GitHub
parent dbaaa8b45d
commit 9962717481
6 changed files with 257 additions and 26 deletions

View File

@@ -68,6 +68,16 @@ From the BitBetter directory, simply run:
This will create a new self-signed certificate in the `.keys` directory if one does not already exist and then create a modified version of the official `bitwarden/api` called `bitbetter/api` and a modified version of the `bitwarden/identity` called `bitbetter/identity`.
By default, the build script runs in **fast patch mode**: it pulls the official Bitwarden images from the GitHub Container Registry and patches the license certificate directly inside them — no local compilation required.
To instead **build from source**, set the `BITBETTER_BUILD_FROM_SOURCE` environment variable to `1` before running the script:
```bash
BITBETTER_BUILD_FROM_SOURCE=1 ./build.sh
```
In source build mode the script clones the Bitwarden server repository at the matching version tag, replaces the embedded license certificate, and compiles the `api` and `identity` images from scratch. This takes considerably longer but gives you full control over the build.
You may now simply create the file `/path/to/bwdata/docker/docker-compose.override.yml` with the following contents to utilize the modified images.
```yaml
@@ -93,6 +103,16 @@ You can now start or restart Bitwarden as normal and the modified api will be us
To update Bitwarden, the provided `update-bitwarden.sh` script can be used. It will rebuild the BitBetter images and automatically update Bitwarden afterwards. Docker pull errors can be ignored for api and identity images.
By default, the images are built in **fast patch mode**: the official Bitwarden images are pulled from the GitHub Container Registry and the license certificate are directly patched inside them.
To instead **build from source**, set the `BITBETTER_BUILD_FROM_SOURCE` environment variable to `1` before running the script:
```bash
BITBETTER_BUILD_FROM_SOURCE=1 ./update-bitwarden.sh
```
In source build mode the script clones the Bitwarden server repository at the matching version tag, replaces the embedded license certificate, and compiles the `api` and `identity` images from scratch. This takes considerably longer but gives you full control over the build.
You can either run this script without providing any parameters in interactive mode (`./update-bitwarden.sh`) or by setting the parameters as follows, to run the script in non-interactive mode:
```bash
./update-bitwarden.sh param1 param2 param3

View File

@@ -28,35 +28,66 @@ echo "Using BUILDPLATFORM=$BUILDPLATFORM TARGETPLATFORM=$TARGETPLATFORM"
# If there aren't any keys, generate them first.
[ -e "$DIR/.keys/cert.cert" ] || "$DIR/.keys/generate-keys.sh"
# Prepare Bitwarden server repository
rm -rf $DIR/server
git clone --branch "v${BW_VERSION}" --depth 1 https://github.com/bitwarden/server.git $DIR/server
if [ "${BITBETTER_BUILD_FROM_SOURCE:-0}" = "1" ]; then
echo "--- Source build mode ---"
# Replace certificate file and thumbprint
old_thumbprint=$(openssl x509 -inform DER -fingerprint -noout -in $DIR/server/src/Core/licensing.cer | cut -d= -f2 | tr -d ':')
new_thumbprint=$(openssl x509 -inform DER -fingerprint -noout -in $DIR/.keys/cert.cert | cut -d= -f2 | tr -d ':')
sed -i -e "s/$old_thumbprint/$new_thumbprint/g" $DIR/server/src/Core/Billing/Services/Implementations/LicensingService.cs
cp $DIR/.keys/cert.cert $DIR/server/src/Core/licensing.cer
# Prepare Bitwarden server repository
rm -rf $DIR/server
git clone --branch "v${BW_VERSION}" --depth 1 https://github.com/bitwarden/server.git $DIR/server
docker build \
--no-cache \
--platform "$TARGETPLATFORM" \
--build-arg BUILDPLATFORM="$BUILDPLATFORM" \
--build-arg TARGETPLATFORM="$TARGETPLATFORM" \
--label com.bitwarden.product="bitbetter" \
-f $DIR/server/src/Api/Dockerfile \
-t bitbetter/api \
$DIR/server
# Replace certificate file and thumbprint
old_thumbprint=$(openssl x509 -inform DER -fingerprint -noout -in $DIR/server/src/Core/licensing.cer | cut -d= -f2 | tr -d ':')
new_thumbprint=$(openssl x509 -inform DER -fingerprint -noout -in $DIR/.keys/cert.cert | cut -d= -f2 | tr -d ':')
sed -i -e "s/$old_thumbprint/$new_thumbprint/g" $DIR/server/src/Core/Billing/Services/Implementations/LicensingService.cs
cp $DIR/.keys/cert.cert $DIR/server/src/Core/licensing.cer
docker build \
--no-cache \
--platform "$TARGETPLATFORM" \
--build-arg BUILDPLATFORM="$BUILDPLATFORM" \
--build-arg TARGETPLATFORM="$TARGETPLATFORM" \
--label com.bitwarden.product="bitbetter" \
-f $DIR/server/src/Identity/Dockerfile \
-t bitbetter/identity \
$DIR/server
docker build \
--no-cache \
--platform "$TARGETPLATFORM" \
--build-arg BUILDPLATFORM="$BUILDPLATFORM" \
--build-arg TARGETPLATFORM="$TARGETPLATFORM" \
--label com.bitwarden.product="bitbetter" \
-f $DIR/server/src/Api/Dockerfile \
-t bitbetter/api \
$DIR/server
docker build \
--no-cache \
--platform "$TARGETPLATFORM" \
--build-arg BUILDPLATFORM="$BUILDPLATFORM" \
--build-arg TARGETPLATFORM="$TARGETPLATFORM" \
--label com.bitwarden.product="bitbetter" \
-f $DIR/server/src/Identity/Dockerfile \
-t bitbetter/identity \
$DIR/server
else
echo "--- Fast patch mode ---"
mkdir -p "$DIR/src/bitBetter/.keys"
cp "$DIR/.keys/cert.cert" "$DIR/src/bitBetter/.keys/cert.cert"
# Build the patcher tool inside the SDK container
docker run --rm \
-v "$DIR/src/bitBetter:/bitBetter" \
-w /bitBetter \
mcr.microsoft.com/dotnet/sdk:8.0 sh build.sh
docker build \
--no-cache \
--platform "$TARGETPLATFORM" \
--label com.bitwarden.product="bitbetter" \
--build-arg BITWARDEN_TAG="ghcr.io/bitwarden/api:$BW_VERSION" \
-t bitbetter/api \
"$DIR/src/bitBetter"
docker build \
--no-cache \
--platform "$TARGETPLATFORM" \
--label com.bitwarden.product="bitbetter" \
--build-arg BITWARDEN_TAG="ghcr.io/bitwarden/identity:$BW_VERSION" \
-t bitbetter/identity \
"$DIR/src/bitBetter"
fi
docker tag bitbetter/api bitbetter/api:latest
docker tag bitbetter/identity bitbetter/identity:latest

13
src/bitBetter/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
ARG BITWARDEN_TAG
FROM ${BITWARDEN_TAG}
COPY bin/Release/net8.0/publish/* /bitBetter/
COPY ./.keys/cert.cert /newLicensing.cer
RUN set -e; set -x; \
if [ -f /app/Api ]; then EXEC=Api; else EXEC=Identity; fi && \
dotnet /bitBetter/bitBetter.dll /newLicensing.cer /app/$EXEC && \
rm -f /app/$EXEC && \
printf '#!/bin/sh\nexec dotnet /app/%s.dll "$@"\n' "$EXEC" > /app/$EXEC && \
chmod +x /app/$EXEC && \
rm -rf /bitBetter /newLicensing.cer

147
src/bitBetter/Program.cs Normal file
View File

@@ -0,0 +1,147 @@
using System;
using System.IO;
using System.Linq;
using System.Security.Cryptography.X509Certificates;
using System.Text.Json.Nodes;
using Mono.Cecil;
using Mono.Cecil.Cil;
using Mono.Cecil.Rocks;
using SingleFileExtractor.Core;
namespace BitwardenSelfLicensor
{
class Program
{
static int Main(string[] args)
{
string cerFile = args.Length >= 1 ? args[0] : "/newLicensing.cer";
string inputPath = args.Length >= 2 ? args[1] : "/app/Api";
string coreDllPath;
string extractDir = Path.GetDirectoryName(Path.GetFullPath(inputPath));
if (string.Equals(Path.GetExtension(inputPath), ".dll", StringComparison.OrdinalIgnoreCase))
{
// Input is already a direct Core.dll path — skip bundle extraction
coreDllPath = inputPath;
}
else
{
try
{
var reader = new ExecutableReader(inputPath);
reader.ExtractToDirectory(extractDir);
Console.WriteLine($"Extracted bundle to {extractDir}");
coreDllPath = Path.Combine(extractDir, "Core.dll");
// The extracted runtimeconfig.json is in self-contained format (no "framework" key).
// Running "dotnet App.dll" requires framework-dependent format; without it .NET looks
// for libhostpolicy.so in the app dir (which isn't there) and crashes.
FixRuntimeConfig(extractDir, Path.GetFileNameWithoutExtension(inputPath));
}
catch (Exception ex)
{
Console.Error.WriteLine($"ERROR: Failed to extract single-file bundle '{inputPath}': {ex.Message}");
return 1;
}
}
Console.WriteLine($"Patching: {coreDllPath}");
var certBytes = File.ReadAllBytes(cerFile);
var module = ModuleDefinition.ReadModule(new MemoryStream(File.ReadAllBytes(coreDllPath)));
// Replace embedded certificate resource
var existingRes = module.Resources
.OfType<EmbeddedResource>()
.FirstOrDefault(r => r.Name == "Bit.Core.licensing.cer");
if (existingRes == null)
{
Console.Error.WriteLine("ERROR: Embedded resource 'Bit.Core.licensing.cer' not found in Core.dll");
return 1;
}
Console.WriteLine($"Found resource: {existingRes.Name}");
module.Resources.Add(new EmbeddedResource("Bit.Core.licensing.cer", existingRes.Attributes, certBytes));
module.Resources.Remove(existingRes);
var existingCert = new X509Certificate2(existingRes.GetResourceData());
var newCert = new X509Certificate2(certBytes);
Console.WriteLine($"Old thumbprint: {existingCert.Thumbprint}");
Console.WriteLine($"New thumbprint: {newCert.Thumbprint}");
// Find LicensingService by class name (namespace-agnostic to handle renames)
var type = module.Types.FirstOrDefault(t => t.Name == "LicensingService");
if (type == null)
{
Console.Error.WriteLine("ERROR: LicensingService not found in Core.dll");
return 1;
}
Console.WriteLine($"Found: {type.FullName}");
var ctor = type.Resolve().GetConstructors().First();
var rewriter = ctor.Body.GetILProcessor();
// Use Contains() to handle the hidden Unicode LRM character (\u200E) that Bitwarden
// prepends to the production thumbprint string literal in LicensingService.cs
var instToReplace = ctor.Body.Instructions
.Where(i => i.OpCode == OpCodes.Ldstr)
.FirstOrDefault(i => ((string)i.Operand)
.Contains(existingCert.Thumbprint, StringComparison.OrdinalIgnoreCase));
if (instToReplace != null)
{
Console.WriteLine($"Replacing thumbprint Ldstr: '{instToReplace.Operand}'");
rewriter.Replace(instToReplace, Instruction.Create(OpCodes.Ldstr, newCert.Thumbprint));
}
else
{
Console.WriteLine("WARNING: Thumbprint Ldstr not found — cert resource replaced anyway");
}
module.Write(coreDllPath);
Console.WriteLine("Done.");
return 0;
}
// Converts a self-contained runtimeconfig.json to framework-dependent so the app can be
// launched with "dotnet App.dll" using the system-installed ASP.NET Core runtime.
static void FixRuntimeConfig(string dir, string appName)
{
var path = Path.Combine(dir, $"{appName}.runtimeconfig.json");
if (!File.Exists(path))
{
Console.WriteLine($"runtimeconfig not found at {path}, skipping");
return;
}
var root = JsonNode.Parse(File.ReadAllText(path))!;
var opts = root["runtimeOptions"]!.AsObject();
// Derive framework name/version from the self-contained includedFrameworks before removing it
string fwName = "Microsoft.AspNetCore.App";
string fwVersion = "8.0.0";
if (opts["includedFrameworks"] is JsonArray included && included.Count > 0)
{
var first = included[0]!.AsObject();
fwName = first["name"]?.GetValue<string>() ?? fwName;
fwVersion = first["version"]?.GetValue<string>() ?? fwVersion;
}
// Remove self-contained markers
opts.Remove("includedFrameworks");
// Add framework-dependent reference (LatestPatch allows compatibility across patch releases only)
opts["framework"] = new JsonObject
{
["name"] = fwName,
["version"] = fwVersion
};
opts["rollForward"] = "LatestPatch";
File.WriteAllText(path, root.ToJsonString(new System.Text.Json.JsonSerializerOptions { WriteIndented = true }));
Console.WriteLine($"Fixed runtimeconfig: {path}");
}
}
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Mono.Cecil" Version="0.11.2" />
<PackageReference Include="SingleFileExtractor.Core" Version="2.3.0" />
</ItemGroup>
</Project>

7
src/bitBetter/build.sh Executable file
View File

@@ -0,0 +1,7 @@
#!/bin/bash
set -e
set -x
dotnet restore
dotnet publish -c Release -o bin/Release/net8.0/publish