From 9962717481e1e9f341e304d7cbebf004f83b1782 Mon Sep 17 00:00:00 2001 From: Lorenzo Moscati Date: Sun, 12 Apr 2026 12:03:25 +0200 Subject: [PATCH] Fast patching via IL rewriting of Bitwarden images (#278) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * Add BITBETTER_BUILD_FROM_SOURCE notes to README.md Signed-off-by: Lorenzo Moscati --------- Signed-off-by: Lorenzo Moscati Co-authored-by: h44z --- README.md | 20 +++++ build.sh | 83 +++++++++++++------ src/bitBetter/Dockerfile | 13 +++ src/bitBetter/Program.cs | 147 +++++++++++++++++++++++++++++++++ src/bitBetter/bitBetter.csproj | 13 +++ src/bitBetter/build.sh | 7 ++ 6 files changed, 257 insertions(+), 26 deletions(-) create mode 100644 src/bitBetter/Dockerfile create mode 100644 src/bitBetter/Program.cs create mode 100644 src/bitBetter/bitBetter.csproj create mode 100755 src/bitBetter/build.sh diff --git a/README.md b/README.md index 03953de..060128f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/build.sh b/build.sh index dcde311..5fcdafe 100755 --- a/build.sh +++ b/build.sh @@ -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 diff --git a/src/bitBetter/Dockerfile b/src/bitBetter/Dockerfile new file mode 100644 index 0000000..b94f9b2 --- /dev/null +++ b/src/bitBetter/Dockerfile @@ -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 diff --git a/src/bitBetter/Program.cs b/src/bitBetter/Program.cs new file mode 100644 index 0000000..c81308e --- /dev/null +++ b/src/bitBetter/Program.cs @@ -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() + .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() ?? fwName; + fwVersion = first["version"]?.GetValue() ?? 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}"); + } + } +} diff --git a/src/bitBetter/bitBetter.csproj b/src/bitBetter/bitBetter.csproj new file mode 100644 index 0000000..5b4b8e2 --- /dev/null +++ b/src/bitBetter/bitBetter.csproj @@ -0,0 +1,13 @@ + + + + Exe + net8.0 + + + + + + + + diff --git a/src/bitBetter/build.sh b/src/bitBetter/build.sh new file mode 100755 index 0000000..6de15ca --- /dev/null +++ b/src/bitBetter/build.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +set -e +set -x + +dotnet restore +dotnet publish -c Release -o bin/Release/net8.0/publish