ARMANOS

Engine

Our browser engine

ARMANOS Browser is a Chromium build we compile ourselves. A profile's device is set inside the engine, in C++, before the page runs a single line of script. This page shows where each value is set, what the engine still does not close, and how a build is accepted before it ships.
ARMANOS Browser 153.0.7978.0
Engine
26 Chromium files, applied in a fixed order
Patch set
9, each proven by a marker in the patched file
Declared capabilities
macOS Apple Silicon and Intel, Windows, Linux
Builds
25, inside the app
Readings per profile

The engine is a Chromium build of our own, not a setting on top of one

ARMANOS Browser 153.0.7978.0 is compiled from Chromium sources with our patches applied at build time. The patches are not a plugin, not an extension and not a launch flag. They change the C++ that answers a page, so the answer leaves the browser already correct.

The version lives in one place: ARMANOS_BROWSER_VERSION in packages/shared/src/index.js. The desktop app reads it, this page reads it, and a profile claims exactly the version that renders it. Claiming a different version would be a mismatch a site can test in one line, comparing the user agent against features that only exist in a given milestone.

Four builds ship, one per platform, and each installer carries its engine inside. Packaging refuses to run when the slot for the target architecture is empty. An installer without an engine would quietly hand the buyer the fallback engine and their real device, and nothing on screen would say so.

The Windows build edits Chromium's BRANDING file before configuring the tree, so chrome.exe and chrome.dll carry the product name in their own version resources. The macOS build is still branded after compiling, which is the weaker order; the next macOS build should do what the Windows one does. Both report ARMANOS Browser 153.0.7978.0 today, and a test reads that straight out of whichever binary sits in the slot.

PlatformWhere the build is droppedWhat the app resolves at runtime
macOS, Apple Siliconapps/desktop/engines/mac-arm64/ARMANOS Browser.appContents/MacOS/ARMANOS Browser inside the bundle
macOS, Intelapps/desktop/engines/mac-x64/ARMANOS Browser.appthe same path inside the Intel build
Windows 10 and 11, 64-bitapps/desktop/engines/win/ with its runtime filesresources/engines/armanos-browser/armanos-browser.exe, or chrome.exe
Ubuntu 22.04+, 64-bitapps/desktop/engines/linux/ with its runtime filesresources/engines/armanos-browser/chrome

The values are set in C++, before the first line of page script

A page cannot be first here. By the time its script asks navigator.platform, the getter that answers has already been replaced in the engine, and there is no earlier moment to hook. That is the whole difference between an engine and a script.

The seed is a single object built by the app and handed over once, on the command line. It is deterministic in the template, the profile id and the proxy exit country, so the same profile gets the same machine on the next launch and on another computer.

When the switch is absent, every patch is inert and the binary behaves like stock Chromium. That is not a nicety: it is what makes the acceptance stands work, because they compare the same binary with a seed against itself without one.

Each profile also gets its own --user-data-dir, so cookies, storage, history and the site's own local state live in a directory that no other profile can see.

  1. 1

    The app builds the seed

    buildFingerprint(template, profile id, proxy geo) in packages/shared/src/index.js returns one object: device, screen, GPU strings, fonts, timezone, languages, media device counts, network numbers and two noise seeds.

  2. 2

    The seed goes on the command line, once

    forkEngine.buildArgs writes --armanos-fingerprint=<base64 JSON>, plus --user-data-dir for this profile, the proxy, the language list and the user agent.

  3. 3

    The browser process decodes it once

    base::Base64Decode and base::JSONReader in third_party/blink/renderer/core/armanos/armanos_fp.cc, held in a process wide singleton.

  4. 4

    Every renderer inherits it

    The switch is added to the forwarded list in content/browser/renderer_host/render_process_host_impl.cc, so a renderer started ten minutes later carries the same seed.

  5. 5

    Blink getters answer from the seed

    navigator_id.cc, screen.cc, font_cache.cc, timezone_controller.cc, network_information.cc and the rest return the seeded value where they used to return the machine's.

  6. 6

    The page runs afterwards

    There is nothing left to override. No prototype was replaced, no function has a rewritten toString, and a worker started from its own URL reads the same values as the main thread.

Surface by surface, this is what a site reads

The patch set touches 26 files of the Chromium tree plus an archive of new files that carry our own code. Below is what each surface answers today. Nothing here is a category: every row names the file the change sits in.

Two rules run through all of it. First, the noise is stable per profile and never fresh per call, because a value that changes between two reads in one session is itself a signal. Second, values are derived from the browser's own rules rather than from what looks plausible. The heap limit is quantized into one of Chromium's hundred steps; the round trip time is rounded to a multiple of 50 ms and the downlink capped at 10 Mbit/s, exactly as network_state_notifier.cc does it; the effective connection type is derived from those numbers using the thresholds in network_quality_estimator_params.h rather than drawn at random.

Device counts are never zero. The engine can hide a camera the machine has, but it cannot take one away: a profile claiming zero cameras would answer "no camera" to enumerateDevices and then reveal one on getUserMedia, and two contradicting answers are louder than any count.

SurfaceWhere the patch sitsWhat the page gets
Canvas readbackarmanos_canvas_noise.cc, base_rendering_context_2d.cc, html_canvas_element.cc, canvas_async_blob_creator.cca stable perturbation on getImageData, toDataURL and toBlob, keyed by coordinate so two reads of one rectangle agree
Audioaudio_buffer.cc, realtime_analyser.cca stable perturbation on the buffer and on frequency data; repeated reads do not accumulate it
WebGL stringswebgl_rendering_context_base.ccthe profile's vendor and renderer, for example Google Inc. (AMD) with an ANGLE line naming a card that exists
WebGL pixelsthe same file, immediately after ReadPixelsHelperreadPixels carries the same perturbation as the canvas path, with the row order flipped to match, so the two read paths agree
WebGPUgpu_buffer.cc, gpu_command_encoder.cc, gpu_queue.ccbuffers that had an image copied into them are perturbed; ordinary compute results are left untouched
Fontsarmanos_font_gate.cc, font_cache.cconly the profile's families resolve; anything else falls back as if it were not installed
Timezonetimezone_controller.ccthe zone that matches the proxy exit country; with no proxy geo nothing is forced
navigator identitynavigator_id.cc, navigator_base.cc, navigator_concurrent_hardware.cc, navigator_device_memory.ccplatform, user agent, vendor, core count, memory, touch points
Client hintsuser_agent_utils.cc, navigator_ua_data.ccSec-CH-UA headers and getHighEntropyValues() come from the same seed as the user agent, so header and script agree
Screenscreen.cc, local_dom_window.ccsize, available size, colour depth and device pixel ratio, kept coherent with each other
Media devicesmedia_devices.ccthe profile's count of microphones, audio outputs and cameras
Speech voicesspeech_synthesis.ccthe voice list is filtered to the profile's language, so a Brazilian profile does not offer Japanese voices
Network informationnetwork_information.ccdownlink, round trip time and effective type, rounded the way Chromium rounds them
Memory limitmemory_info.ccthe heap limit is brought into line with the declared memory, and left alone when the two already agree

A script cannot match this, and the gap is the first thing a checker looks for

A fingerprint checker does not only read values. It compares what the browser declares against what it can measure, and reports the distance between the two. That distance is what page level spoofing creates.

Take fonts. A script can rewrite measureText and document.fonts.check, but it cannot change which glyphs the system actually draws, and it cannot reach the CSS parser or a worker realm. Take canvas. Every read path has to be patched separately, and a single missed one returns the true picture while the others return the altered one, which proves tampering rather than hiding anything. Take a worker: its script can live at its own URL, which page JavaScript cannot rewrite, so a wrapper covers Blob and data sources and nothing else.

We measured all three on both engines. The table below is that measurement, not an argument. A page that sees a seeded machine on the main thread and the real one in a worker has proof the browser is being altered, which is worse than an honest mismatch would have been.

This is also why we removed a page level language override once the engine started reporting the list itself. A masked getter is a surface to be caught on. Engine truth is not.

Injected JavaScriptARMANOS Browser
Where the value comes froma replaced getter on a prototypethe getter itself
Worker started from its own URLthe real machinethe seeded machine
iframe created and read in the same tickthe real machinethe seeded machine
Font resolutionmeasurement is rewritten, drawing is notthe font cache resolves only the profile's families
Two read paths of one drawingeach path must be patched by hand, and a missed one disagreesboth come from one place in the engine

The engine says what it closes, and the page layer steps back

Not everything is in C++ yet. What the engine does not close, the app closes from the page, before any script of the site runs. That is honest and it is written down, but it creates a trap: on the day a newer engine reaches a user, the two layers would land on top of each other. The engine would add noise to a pixel read, the page would add its own on top, and the same drawing read through the canvas would come back different. That disagreement between two read paths is exactly the tell the whole effort exists to remove, only self inflicted.

So the build writes a capability manifest next to the binary, armanos-engine.json, and the app reads it at launch and switches off exactly the page level overrides the engine now handles itself. Asking the binary for its version would not work: the fork reports the same version string a stock Chromium of that milestone reports.

The manifest is derived from the tree, not copied from a list. Each capability is proven by a marker string that must be present in the patched file, for example armanos::ShouldHideSystemFont( in font_cache.cc. Before that, a build declared every capability the code mentioned, whether or not the patch had landed, which is a claim rather than a measurement. The price of that lie was concrete: the app opens the three Android connection fields only for an engine that declares net-mobile-fields, and opening them on an engine that cannot substitute the values would have exposed the host's own "wifi" and an infinite downlinkMax.

  • webgl-readpixels

    noise on WebGL pixel reads inside the engine

  • intl

    timezone, locale, calendar and number separators

  • fonts

    the profile's font list rather than the machine's

  • devices

    counts of input and output devices

  • voices

    speech voices filtered to the declared language

  • net

    speed, latency and connection kind

  • memory

    heap limit brought into line with the declared memory

  • webgpu

    noise on image snapshots taken through WebGPU

  • net-mobile-fields

    the three Android connection fields: type, downlinkMax, ontypechange

The built-in engine is a real fallback, and it is weaker

The app ships with a second engine: real Chromium through Electron, currently milestone 130, with fingerprint protection injected as JavaScript. It works on every platform and it is what a profile falls back to when ARMANOS Browser cannot start. Since every installer now carries a fork inside, a fresh install runs on ARMANOS Browser from the first launch.

The fallback is never silent. If the fork fails to start, the profile still opens, but a notification names the profile and the reason. For a product like this, quietly changing which engine wears the identity is the one fallback that must never pass unannounced: the Engine screen would still read ARMANOS Browser while the profile ran JS level injection on a different Chromium version.

One difference deserves to be said plainly, because it looks like it works and does not. A blocking extension you install from the catalog loads on the built-in engine, shows as enabled, and blocks nothing: Electron does not execute declarativeNetRequest rules. We measured it with one extension on both engines, a rule against a page that fetches /blockme. On the built-in engine the server saw the request. On ARMANOS Browser it did not. While that stays true, the Extensions screen carries a warning, and a test forces the warning to be removed if Electron ever fixes it.

Our own tracker blocking and link cleaning do work on the built-in engine, because the app executes those rules itself instead of relying on the browser. RPA Plus and the Synchronizer work on both engines too.

Built-in engine, Chromium 130ARMANOS Browser, Chromium 153
Where the fingerprint is setin the page, by injected JavaScriptin C++, before the first script
Worker realm matches the main threadOui
iframe realm matches the parentOui
A blocking extension from the catalog actually blocksOui
Our tracker blocking and link cleaningOuiOui
RPA Plus and the SynchronizerOuiOui
Available on every platformOuiOui
The Browser engine screen of the desktop app: two engines listed, ARMANOS Browser marked installed and active, the detected binary path below
The Engine screen names both engines and the version of each, shows the exact binary the app resolved, and states the fallback rule in the same place.

What rides along with the engine

The fork is a separate Chromium process with no Electron session and no preload, so none of the protections written against Electron carry over by themselves. They are delivered instead by a small extension generated per profile at launch and loaded with --load-extension, together with --disable-extensions-except so that nothing else in the profile directory can load alongside it.

Blocking happens inside the network stack through declarativeNetRequest. There is no page visible hook and no round trip to our process per request, which also means a hiccup in our app cannot stall the user's browsing. The rules are compiled from the same list the built-in engine uses, so the two engines cannot drift apart.

  • Tracker blocking

    Three rulesets: blocklist, first party telemetry on known platforms, and tracking parameters. The extension's service worker reports what matched, which is what fills the Protection screen.

  • Link cleaning

    40 tracking parameters are stripped on navigation, before the request leaves the machine, and the address bar shows the already clean address.

  • WebRTC through the proxy

    --force-webrtc-ip-handling-policy=disable_non_proxied_udp, so local candidates are not offered around the proxy.

  • All traffic on the proxy

    --proxy-server covers every scheme, not only https. An unknown proxy type makes the launch refuse rather than quietly go direct.

  • Proxy passwords off the command line

    Chromium rejects credentials inside --proxy-server, and the password would be visible in any process listing anyway. It travels through a loopback bridge instead, and the browser authenticates to that bridge from the extension.

  • Automation without the tell

    The debugging port is bound to loopback and chosen by the OS. Opening it sets navigator.webdriver, so the automation bit is cleared with a launch flag; RPA Plus and the Synchronizer drive the browser through that port.

  • Its own encryption key

    --use-mock-keychain and --password-store=basic keep the browser out of the OS credential store, which also keeps a profile portable between machines.

A build is not shipped until the stands accept it

One command runs the acceptance, writes one report and returns one exit code. A stand that could not be started counts as failed, never as skipped: a silent skip is precisely how an unaccepted build once reached users with someone else's name in its menu.

The stands measure the finished binary, not the source. They launch it, read what a page sees, and compare against the same binary with no seed, which makes the clean reference the engine itself rather than a Chrome of a different milestone.

The result has three outcomes, not two. Accepted means every stand passed. Accepted with a caveat means nothing failed but something could not be checked in full, usually because no reference Chrome of the same milestone was installed. Not accepted means at least one stand failed, and such a build is not published.

A separate test guards the patch set itself, and it exists because of a real failure: ten numbered files in the patch directory looked like a finished series and were all placeholders. The builder skipped each with a warning and produced plain Chromium, while the real patch lay beside them and was never listed. The test now requires the applied list to contain no placeholders, requires each named patch to change real Chromium sources, requires the WebGL noise to have a call site after the pixel read that carries the buffer height, and requires every declared capability to be proven by its marker.

  1. 1

    Seeded values arrive

    accept-engine.js launches the binary with a proxy geo and reads back what a page sees, then cross-checks the same binary unseeded.

  2. 2

    Invariants hold across several seeds

    engine-invariants.js runs four seeds by default. A canvas defect can depend on the seed, so one lucky profile would let a broken build pass.

  3. 3

    The API shape is recorded

    compare-shape.js writes the whole surface of our engine to a file rather than printing a line nobody compares.

  4. 4

    A real Chrome is recorded too

    the same script runs against a stock Chrome, with no seed, into a second file.

  5. 5

    The two shapes are compared

    сравнить-форму.js diffs them. Exit code 3 means partially compared; code 4 means extra properties nobody could explain, which is a refusal, because that is a difference a site finds with one line.

How to check it yourself

Every profile opens on a start page that lists the checkers this audience already uses: BrowserScan, BrowserLeaks, CreepJS, Pixelscan and an IP lookup. The page is served by a loopback server inside the app, so opening a profile is not announced to any third party. The only outbound request it makes is the IP lookup, and that travels through the profile's own proxy, so the address you see is the address a site sees.

Inside the app there is a self check that reads 25 values from a live page of the profile and compares each one against what the seed asked for. It covers the automation bit, the platform and user agent, the GPU strings, screen, cores and memory, canvas stability and three canvas invariants, the WebGL readback, audio stability, timezone, the whole language list, plugins, the frame and worker realms, fonts and the client hint fields. Mobile profiles add touch, orientation and window checks.

The self check refuses to run on a window without protection instead of scoring it. A profile without spoofing would fail readings that a real profile never fails, and a red screen that means nothing teaches you to ignore red screens.

The readout below is one profile's values, generated from the same function the engine is handed. You can reproduce it for your own profile from the start page before you visit anything.

navigator.platform
Win32
navigator.userAgent
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36
Cores and memory
12 logical cores, 8 GB
Screen
1920 x 1080, available height 1040, pixel ratio 1
WebGL vendor
Google Inc. (AMD)
WebGL renderer
ANGLE (AMD, AMD Radeon RX 6600 (0x000073FF) Direct3D11 vs_5_0 ps_5_0, D3D11)
Timezone
America/New_York
Languages
en-US, en
Fonts
25 families, all of them Windows ones
Media devices
1 microphone, 3 audio outputs, 1 camera
Network
4g, 10 Mbit/s downlink, 200 ms round trip
Speech voices
English only
maxTouchPoints
0

One profile on the windows-chrome template, proxy exiting in the United States. Every value is derived from the profile id, so it is identical on the next launch and on another computer. Device counts are a ceiling: the engine can hide a device the machine has, it cannot invent one it lacks.

What this does not do

  • WebGL pixel reads are covered for 8-bit RGBA, which is the path checkers use. Three paths are still open and named as open in engine/patches/PENDING.md: RGB at three bytes per pixel, float and half-float types, and reads that go through a pixel pack buffer.
  • The real system font inventory still leaks through a local() lookup. Measurement is gated by the profile's list, but a lookup by unique name resolves what the machine actually has, and closing that needs a patch in local_font_face_source.cc.
  • On macOS the ICU default locale follows the host machine, so Intl formatters can format in the host's language while the profile declares another. Stock Chromium does the same with the same flags. Until the engine sets ICU's locale from the seed, the page level layer pins timezone and language.
  • A profile whose template does not match the host gets the host's stock font families added, otherwise text does not render at all. Those names are visible to a page, and this cannot be fixed without splitting the font list into one for drawing and one for answering, which is engine work.
  • The automation bit is cleared by a public Chromium switch, not by a patch. The switch works and is guarded by a test that launches the real engine and reads the value from a real page, but it is visible in the process command line to anything on the machine that can read it.
  • What this sells is isolation and a consistent device, not invisibility. A site still sees your IP, your behaviour and the history of the account you log into, and the engine has no opinion about any of them.

How to check

Each of these is a file you can open and a stand that fails the build when the claim stops being true.

The fingerprint is substituted inside Chromium sources, applied at build time
engine/patches/series · apps/desktop/test/engine-patches-real.js
The WebGL readback noise is actually called, not dead code sitting in the patch
engine/patches/armanos-fp.cumulative.diff · apps/desktop/test/engine-patches-real.js
Every declared capability is proven by a marker in the patched file, not by a list
packages/shared/src/index.js · engine/scripts/опись-умений.js
The page level layer retires itself exactly where the engine takes over
apps/desktop/src/lib/дыры-страницы.js · apps/desktop/test/страница-отходит-когда-движок-умеет.js
A build is accepted by stands, and a partial check is not passed off as a full one
engine/scripts/приёмка.sh · apps/desktop/test/сборочные-скрипты.js
The installer refuses to ship without an engine, or with an engine of the wrong version
apps/desktop/build/before-pack.js · apps/desktop/test/установщик-без-движка.js

Run it on your own machine

Install ARMANOS, open a profile, and read the start page before you visit anything. The engine ships inside the app.