1212 * can appear in the same database, so both are derived up front and chosen
1313 * per record.
1414 *
15- * Windows is not supported: since Chrome 127 its cookies are encrypted to the
16- * browser's own identity ( App-Bound Encryption), unreadable by any other app .
15+ * - ** Windows** legacy Chromium stores protect a random AES key with DPAPI.
16+ * App-Bound Encryption remains deliberately unsupported .
1717 *
1818 * @module ChromiumKeys
1919 */
@@ -23,6 +23,8 @@ import * as NodeCrypto from "node:crypto";
2323import { HostProcessEnvironment } from "@t3tools/shared/hostProcess" ;
2424
2525import * as Effect from "effect/Effect" ;
26+ import * as Encoding from "effect/Encoding" ;
27+ import * as FileSystem from "effect/FileSystem" ;
2628import * as Schema from "effect/Schema" ;
2729import * as Stream from "effect/Stream" ;
2830import { ChildProcess , ChildProcessSpawner } from "effect/unstable/process" ;
@@ -77,6 +79,8 @@ export interface ChromiumKeyMaterial {
7779 * after a record's own key fails.
7880 */
7981 readonly cbcEmpty ?: Buffer ;
82+ /** AES-256-GCM key used by pre-App-Bound Chromium on Windows. */
83+ readonly gcmV10 ?: Buffer ;
8084}
8185
8286const derive = ( passphrase : string , iterations : number ) =>
@@ -172,6 +176,112 @@ export const readLinuxSecret = Effect.fn("ChromiumKeys.readLinuxSecret")(functio
172176 ) ;
173177} ) ;
174178
179+ const WindowsLocalState = Schema . Struct ( {
180+ os_crypt : Schema . Struct ( {
181+ encrypted_key : Schema . String ,
182+ app_bound_encrypted_key : Schema . optional ( Schema . String ) ,
183+ } ) ,
184+ } ) ;
185+ const decodeWindowsLocalState = Schema . decodeUnknownEffect (
186+ Schema . fromJsonString ( WindowsLocalState ) ,
187+ ) ;
188+ const DPAPI_PREFIX = Buffer . from ( "DPAPI" ) ;
189+ const WINDOWS_KEY_LENGTH = 32 ;
190+ const WINDOWS_DPAPI_SCRIPT =
191+ "Add-Type -AssemblyName System.Security;" +
192+ "$value=[Console]::In.ReadToEnd();" +
193+ "$encrypted=[Convert]::FromBase64String($value);" +
194+ "$plain=[Security.Cryptography.ProtectedData]::Unprotect($encrypted,$null,[Security.Cryptography.DataProtectionScope]::CurrentUser);" +
195+ "[Console]::Out.Write([Convert]::ToBase64String($plain))" ;
196+
197+ export const decodeWindowsWrappedKey = Effect . fn ( "ChromiumKeys.decodeWindowsWrappedKey" ) ( function * (
198+ contents : string ,
199+ ) {
200+ const state = yield * decodeWindowsLocalState ( contents ) . pipe (
201+ Effect . mapError ( ( cause ) => new ChromiumKeyError ( { reason : "readFailed" , cause } ) ) ,
202+ ) ;
203+ if ( state . os_crypt . app_bound_encrypted_key !== undefined ) {
204+ return yield * new ChromiumKeyError ( { reason : "unsupportedPlatform" } ) ;
205+ }
206+ const wrapped = yield * Effect . fromResult (
207+ Encoding . decodeBase64 ( state . os_crypt . encrypted_key ) ,
208+ ) . pipe ( Effect . mapError ( ( cause ) => new ChromiumKeyError ( { reason : "readFailed" , cause } ) ) ) ;
209+ const wrappedBuffer = Buffer . from ( wrapped ) ;
210+ if ( ! wrappedBuffer . subarray ( 0 , DPAPI_PREFIX . length ) . equals ( DPAPI_PREFIX ) ) {
211+ return yield * new ChromiumKeyError ( { reason : "readFailed" } ) ;
212+ }
213+ return wrappedBuffer . subarray ( DPAPI_PREFIX . length ) ;
214+ } ) ;
215+
216+ /** Unwraps a key with the current Windows user's DPAPI identity. */
217+ export const unwrapWindowsDpapiKey = Effect . fn ( "ChromiumKeys.unwrapWindowsDpapiKey" ) ( function * (
218+ wrapped : Buffer ,
219+ ) {
220+ const environment = yield * HostProcessEnvironment ;
221+ return yield * Effect . scoped (
222+ Effect . gen ( function * ( ) {
223+ const spawner = yield * ChildProcessSpawner . ChildProcessSpawner ;
224+ const windowsRoot = environment . SystemRoot ?? environment . WINDIR ;
225+ const powershell = windowsRoot
226+ ? `${ windowsRoot } \\System32\\WindowsPowerShell\\v1.0\\powershell.exe`
227+ : "powershell.exe" ;
228+ const handle = yield * spawner
229+ . spawn (
230+ ChildProcess . make (
231+ powershell ,
232+ [
233+ "-NoLogo" ,
234+ "-NoProfile" ,
235+ "-NonInteractive" ,
236+ "-WindowStyle" ,
237+ "Hidden" ,
238+ "-Command" ,
239+ WINDOWS_DPAPI_SCRIPT ,
240+ ] ,
241+ {
242+ env : environment ,
243+ stdin : Stream . encodeText ( Stream . make ( wrapped . toString ( "base64" ) ) ) ,
244+ } ,
245+ ) ,
246+ )
247+ . pipe (
248+ Effect . mapError (
249+ ( cause ) => new ChromiumKeyError ( { reason : "keychainUnavailable" , cause } ) ,
250+ ) ,
251+ ) ;
252+ const [ plainEncoded , , exitCode ] = yield * Effect . all (
253+ [
254+ handle . stdout . pipe ( Stream . decodeText ( ) , Stream . mkString ) ,
255+ handle . stderr . pipe ( Stream . runDrain ) ,
256+ handle . exitCode ,
257+ ] ,
258+ { concurrency : "unbounded" } ,
259+ ) . pipe ( Effect . mapError ( ( cause ) => new ChromiumKeyError ( { reason : "readFailed" , cause } ) ) ) ;
260+ if ( Number ( exitCode ) !== 0 ) {
261+ return yield * new ChromiumKeyError ( { reason : "readFailed" } ) ;
262+ }
263+ const plain = yield * Effect . fromResult ( Encoding . decodeBase64 ( plainEncoded ) ) . pipe (
264+ Effect . mapError ( ( cause ) => new ChromiumKeyError ( { reason : "readFailed" , cause } ) ) ,
265+ ) ;
266+ if ( plain . length !== WINDOWS_KEY_LENGTH ) {
267+ return yield * new ChromiumKeyError ( { reason : "readFailed" } ) ;
268+ }
269+ return Buffer . from ( plain ) ;
270+ } ) ,
271+ ) ;
272+ } ) ;
273+
274+ /** Reads and unwraps a legacy Windows Chromium key without exposing it in argv. */
275+ export const readWindowsKey = Effect . fn ( "ChromiumKeys.readWindowsKey" ) ( function * (
276+ localStatePath : string ,
277+ ) {
278+ const fileSystem = yield * FileSystem . FileSystem ;
279+ const contents = yield * fileSystem
280+ . readFileString ( localStatePath )
281+ . pipe ( Effect . mapError ( ( cause ) => new ChromiumKeyError ( { reason : "readFailed" , cause } ) ) ) ;
282+ return yield * unwrapWindowsDpapiKey ( yield * decodeWindowsWrappedKey ( contents ) ) ;
283+ } ) ;
284+
175285export interface ChromiumKeyRequest {
176286 readonly platform : NodeJS . Platform ;
177287 readonly keychainService : string | undefined ;
0 commit comments