Engine
Our browser engine
- 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.
| Platform | Where the build is dropped | What the app resolves at runtime |
|---|---|---|
| macOS, Apple Silicon | apps/desktop/engines/mac-arm64/ARMANOS Browser.app | Contents/MacOS/ARMANOS Browser inside the bundle |
| macOS, Intel | apps/desktop/engines/mac-x64/ARMANOS Browser.app | the same path inside the Intel build |
| Windows 10 and 11, 64-bit | apps/desktop/engines/win/ with its runtime files | resources/engines/armanos-browser/armanos-browser.exe, or chrome.exe |
| Ubuntu 22.04+, 64-bit | apps/desktop/engines/linux/ with its runtime files | resources/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
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
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
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
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
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
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.
| Surface | Where the patch sits | What the page gets |
|---|---|---|
| Canvas readback | armanos_canvas_noise.cc, base_rendering_context_2d.cc, html_canvas_element.cc, canvas_async_blob_creator.cc | a stable perturbation on getImageData, toDataURL and toBlob, keyed by coordinate so two reads of one rectangle agree |
| Audio | audio_buffer.cc, realtime_analyser.cc | a stable perturbation on the buffer and on frequency data; repeated reads do not accumulate it |
| WebGL strings | webgl_rendering_context_base.cc | the profile's vendor and renderer, for example Google Inc. (AMD) with an ANGLE line naming a card that exists |
| WebGL pixels | the same file, immediately after ReadPixelsHelper | readPixels carries the same perturbation as the canvas path, with the row order flipped to match, so the two read paths agree |
| WebGPU | gpu_buffer.cc, gpu_command_encoder.cc, gpu_queue.cc | buffers that had an image copied into them are perturbed; ordinary compute results are left untouched |
| Fonts | armanos_font_gate.cc, font_cache.cc | only the profile's families resolve; anything else falls back as if it were not installed |
| Timezone | timezone_controller.cc | the zone that matches the proxy exit country; with no proxy geo nothing is forced |
| navigator identity | navigator_id.cc, navigator_base.cc, navigator_concurrent_hardware.cc, navigator_device_memory.cc | platform, user agent, vendor, core count, memory, touch points |
| Client hints | user_agent_utils.cc, navigator_ua_data.cc | Sec-CH-UA headers and getHighEntropyValues() come from the same seed as the user agent, so header and script agree |
| Screen | screen.cc, local_dom_window.cc | size, available size, colour depth and device pixel ratio, kept coherent with each other |
| Media devices | media_devices.cc | the profile's count of microphones, audio outputs and cameras |
| Speech voices | speech_synthesis.cc | the voice list is filtered to the profile's language, so a Brazilian profile does not offer Japanese voices |
| Network information | network_information.cc | downlink, round trip time and effective type, rounded the way Chromium rounds them |
| Memory limit | memory_info.cc | the 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 JavaScript | ARMANOS Browser | |
|---|---|---|
| Where the value comes from | a replaced getter on a prototype | the getter itself |
| Worker started from its own URL | the real machine | the seeded machine |
| iframe created and read in the same tick | the real machine | the seeded machine |
| Font resolution | measurement is rewritten, drawing is not | the font cache resolves only the profile's families |
| Two read paths of one drawing | each path must be patched by hand, and a missed one disagrees | both 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 130 | ARMANOS Browser, Chromium 153 | |
|---|---|---|
| Where the fingerprint is set | in the page, by injected JavaScript | in C++, before the first script |
| Worker realm matches the main thread | – | 是 |
| iframe realm matches the parent | – | 是 |
| A blocking extension from the catalog actually blocks | – | 是 |
| Our tracker blocking and link cleaning | 是 | 是 |
| RPA Plus and the Synchronizer | 是 | 是 |
| Available on every platform | 是 | 是 |

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
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
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
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
A real Chrome is recorded too
the same script runs against a stock Chrome, with no seed, into a second file.
- 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
Keep reading
How ARMANOS works
The path from a profile to an open browser window, step by step.
What the app can do
Profiles, groups, teams, automation and the rest, with the files behind them.
Fingerprint checker
See what your current browser reports right now, in this tab.
Proxies
How a profile's traffic leaves, and what the engine does with the exit country.
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.