diff --git a/docs/hosted.md b/docs/hosted.md index ae902ed..ef27bae 100644 --- a/docs/hosted.md +++ b/docs/hosted.md @@ -44,9 +44,9 @@ Pending uploads expire after five minutes; the one-minute cron reclaims them, ex ## Deploy prerequisites -1. Node.js 22+ and a Cloudflare account with Workers Paid, D1 and R2 enabled. The hosted config sets a 1,000 ms CPU ceiling for scrypt password work; Cloudflare rejects that setting on Workers Free. Upgrading a plan requires operator approval. No email service or sender domain is required. +1. Node.js 22+ and a Cloudflare account with Workers, D1 and R2 enabled. The hosted config supports deployment on Workers Free without a custom CPU limit; measure authentication CPU on your deployment before opening registration. No email service or sender domain is required. 2. Dedicated Worker `shotsync-hosted`, R2 bucket `shotsync-hosted`, and D1 database `shotsync-hosted`. Never bind the personal or demo bucket. Put the returned D1 UUID into `wrangler.hosted.jsonc`. -3. Set `PUBLIC_ORIGIN` to the final HTTPS origin, and `TURNSTILE_SITE_KEY` to a widget restricted to that hostname. Store `TURNSTILE_SECRET_KEY` as a Worker secret. No other site's Turnstile keys are reused. +3. Set `PUBLIC_ORIGIN` to the final HTTPS origin, and `TURNSTILE_SITE_KEY` to a widget restricted to that hostname. Store `TURNSTILE_SECRET_KEY` and `PASSWORD_PEPPER` as Worker secrets. Generate the pepper as 32 cryptographically random bytes encoded as 64 lowercase hex characters; never commit it or store it in D1. Missing or malformed pepper disables password operations. No other site's Turnstile keys are reused. 4. Configure the bucket's eight-day lifecycle and observability/billing alerts. Review registration and upload caps. Use a custom domain if stronger edge rules are needed. 5. With explicit deployment authorization: `npm run deploy:hosted`. It checks placeholders, applies the new hosted database migrations, then deploys the Worker. Do not run any personal/demo setup or seed scripts. 6. Test registration, saving the recovery code, login, recovery-code rotation, rejection of old credentials, and cross-device transfer. Confirm Turnstile hostname validation, cron cleanup and dashboard metrics. @@ -60,10 +60,10 @@ The checked-in config identifies the operator's dedicated hosted resources. For - `npx playwright install chromium && npm run test:browser`: isolated temporary local D1/R2, HTTPS browser login, upload/preview, device token access, anonymous denial, share/revoke, deletion and logout. Never contacts production or sends mail. - `npx wrangler deploy --config wrangler.hosted.jsonc --dry-run`. -To suspend new writes, set `UPLOADS_ENABLED=0` and deploy. Retain the hosted database/bucket; do not drop tables or remove user data during rollback. The original self-hosted app and demo are independent entry points. Recovery codes, session tokens and device tokens are stored as hashes; password hashes use scrypt N=16384/r=8/p=5 with random salts. +To suspend new writes, set `UPLOADS_ENABLED=0` and deploy. Retain the hosted database/bucket; do not drop tables or remove user data during rollback. The original self-hosted app and demo are independent entry points. Recovery codes, session tokens and device tokens are stored as hashes; password verifiers use PBKDF2-HMAC-SHA256 (100,000 iterations, random 32-byte salt), followed by HMAC-SHA256 with the independent pepper. Only the final HMAC is stored in D1. The versioned format is `pbkdf2-sha256:v1:100000::`. This iteration count is below the OWASP PBKDF2 recommendation: it is an explicit free-tier tradeoff, not equivalent to the previous scrypt strength. A database-only leak does not include the pepper; a database plus pepper leak permits cheaper password guessing. Keep the pepper stable: replacing or losing it invalidates existing password verifiers, requiring users to reset with their recovery codes. Older scrypt verifiers require recovery-code reset; the operator confirmed zero hosted accounts before this switch. Official references: [D1 transactions](https://developers.cloudflare.com/d1/worker-api/d1-database/), [R2 lifecycle](https://developers.cloudflare.com/r2/buckets/object-lifecycles/). Toolchain note: the compatible Vitest/Workers test stack currently reports development-only npm advisories (8 at implementation time); these packages are not imported by the deployed Worker. Run development servers on loopback only. The package resolver rejected the newest advertised Wrangler version with a publication-date cutoff; this change uses the resolved lockfile and its supported compatibility date. Track the toolchain updates separately before exposing any development server. -Launch status (2026-09-19): the dedicated D1, R2 bucket and Turnstile widget have been created, all three migrations applied, and the eight-day `users/` R2 expiry configured. Worker deployment is blocked until Workers Paid is enabled; the Turnstile secret still needs to be installed. The service is not publicly available yet. Remote D1 rejected unparenthesized CASE expressions in trigger bodies; migration 0002 now parenthesizes those expressions without changing quota behavior. +Launch preparation (2026-09-19): dedicated D1/R2/Turnstile resources, all three migrations and eight-day object expiry are configured. Cron capacity has been freed. The password implementation now supports a Free-plan deployment; public availability and production CPU checks are recorded after deployment. Remote D1 required parenthesized CASE expressions in migration 0002 without changing quota behavior. diff --git a/scripts/check-hosted-config.mjs b/scripts/check-hosted-config.mjs index b35952b..8fd7b9d 100644 --- a/scripts/check-hosted-config.mjs +++ b/scripts/check-hosted-config.mjs @@ -9,4 +9,4 @@ if(!c.vars.TURNSTILE_SITE_KEY)problems.push('TURNSTILE_SITE_KEY is required'); if(!c.d1_databases?.[0]?.database_id || c.d1_databases[0].database_id==='00000000-0000-0000-0000-000000000000')problems.push('Set the dedicated hosted D1 database ID'); if(c.name!=='shotsync-hosted' || c.r2_buckets?.[0]?.bucket_name!=='shotsync-hosted')problems.push('Hosted Worker and bucket must remain separate from personal/demo instances'); if(problems.length){console.error(problems.join('\n'));process.exit(1);} -console.log('Hosted config ready. Confirm Turnstile secret, bucket lifecycle, and deployment authorization before publishing.'); +console.log('Hosted config ready. Confirm PASSWORD_PEPPER and Turnstile secrets, bucket lifecycle, and deployment authorization before publishing.'); diff --git a/scripts/test-hosted-browser.mjs b/scripts/test-hosted-browser.mjs index c5e7d15..4bf16e4 100644 --- a/scripts/test-hosted-browser.mjs +++ b/scripts/test-hosted-browser.mjs @@ -1,6 +1,6 @@ import { chromium, expect } from '@playwright/test'; import { execFileSync, spawn } from 'node:child_process'; -import { scryptSync } from 'node:crypto'; +import { pbkdf2Sync, createHmac } from 'node:crypto'; import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -14,12 +14,12 @@ try { run(['d1','migrations','apply','shotsync-hosted','--local',...common]); const password='browser-fixture-password'; // Valid format salt; test account exists only in the temporary local database. - const validSalt='a'.repeat(64); - const validHash='scrypt:16384:8:5:'+validSalt+':'+scryptSync(password,validSalt,32,{N:16384,r:8,p:5,maxmem:32*1024*1024}).toString('hex'); + const validSalt='a'.repeat(64), pepper='b'.repeat(64); + const validHash='pbkdf2-sha256:v1:100000:'+validSalt+':'+createHmac('sha256',Buffer.from(pepper,'hex')).update(pbkdf2Sync(password,Buffer.from(validSalt,'hex'),100000,32,'sha256')).digest('hex'); const sql=join(temp,'fixture.sql'); writeFileSync(sql,`INSERT INTO users(id,email,password_hash,verified_at,created_at) VALUES('browser','browser@example.com','${validHash}',NULL,1);`); run(['d1','execute','shotsync-hosted','--local','--file',sql,...common]); - server=spawn(process.execPath,[cli,'dev','--local','--ip','127.0.0.1','--local-protocol','https','--port','8788','--var','PUBLIC_ORIGIN:'+origin,'--var','TURNSTILE_SITE_KEY:',...common],{stdio:['ignore','pipe','pipe']}); + server=spawn(process.execPath,[cli,'dev','--local','--ip','127.0.0.1','--local-protocol','https','--port','8788','--var','PUBLIC_ORIGIN:'+origin,'--var','TURNSTILE_SITE_KEY:','--var','PASSWORD_PEPPER:'+pepper,...common],{stdio:['ignore','pipe','pipe']}); let output='';server.stdout.on('data',x=>output+=x);server.stderr.on('data',x=>output+=x); await new Promise((resolve,reject)=>{const started=Date.now();const timer=setInterval(()=>{if(output.includes('Ready on')){clearInterval(timer);resolve();}else if(server.exitCode!==null||Date.now()-started>30000){clearInterval(timer);reject(new Error(output));}},100);}); browser=await chromium.launch({headless:true}); diff --git a/src/hosted/account-crypto.ts b/src/hosted/account-crypto.ts index 85d7cd2..d174d0b 100644 --- a/src/hosted/account-crypto.ts +++ b/src/hosted/account-crypto.ts @@ -1,5 +1,5 @@ import { HttpError } from './http'; -import { scrypt, timingSafeEqual } from 'node:crypto'; +import { timingSafeEqual } from 'node:crypto'; export function randomToken(): string { return Array.from(crypto.getRandomValues(new Uint8Array(32)), b => b.toString(16).padStart(2, '0')).join(''); @@ -7,22 +7,37 @@ export function randomToken(): string { export async function tokenHash(value: string): Promise { return Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))), b => b.toString(16).padStart(2, '0')).join(''); } -function derive(password: string, salt: string): Promise { - return new Promise((resolve, reject) => scrypt(password, salt, 32, - { N: 16384, r: 8, p: 5, maxmem: 32 * 1024 * 1024 }, - (error, key) => error ? reject(error) : resolve(key))); +const HASH_PREFIX = 'pbkdf2-sha256:v1:100000'; +const HEX_32 = /^[a-f0-9]{64}$/; +function decodeHex(value: string): Uint8Array { + return Uint8Array.from(value.match(/../g)!, b => parseInt(b, 16)); } -export async function hashPassword(password: string): Promise { +export function validPasswordPepper(value: unknown): value is string { + return typeof value === 'string' && HEX_32.test(value); +} +function requirePepper(pepper: string): void { + if (!validPasswordPepper(pepper)) throw new HttpError(503, 'Password authentication is temporarily unavailable'); +} +async function derive(password: string, salt: string, pepper: string): Promise { + const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits']); + const derived = await crypto.subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt: decodeHex(salt), iterations: 100_000 }, key, 256); + const pepperKey = await crypto.subtle.importKey('raw', decodeHex(pepper), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); + // Persist only the peppered verifier, never the intermediate PBKDF2 result. + return new Uint8Array(await crypto.subtle.sign('HMAC', pepperKey, derived)); +} +export async function hashPassword(password: string, pepper: string): Promise { + requirePepper(pepper); const salt = randomToken(); - return `scrypt:16384:8:5:${salt}:${Array.from(await derive(password, salt), b => b.toString(16).padStart(2, '0')).join('')}`; + return `${HASH_PREFIX}:${salt}:${Array.from(await derive(password, salt, pepper), b => b.toString(16).padStart(2, '0')).join('')}`; } -export async function verifyPassword(password: string, stored: string): Promise { +export async function verifyPassword(password: string, stored: string, pepper: string): Promise { + requirePepper(pepper); const parts = stored.split(':'); - if (parts.length !== 6 || parts.slice(0, 4).join(':') !== 'scrypt:16384:8:5' || !/^[a-f0-9]{64}$/.test(parts[4]) || !/^[a-f0-9]{64}$/.test(parts[5])) return false; - return timingSafeEqual(await derive(password, parts[4]), Uint8Array.from(parts[5].match(/../g)!, b => parseInt(b, 16))); + if (parts.length !== 5 || parts.slice(0, 3).join(':') !== HASH_PREFIX || !HEX_32.test(parts[3]) || !HEX_32.test(parts[4])) return false; + return timingSafeEqual(await derive(password, parts[3], pepper), decodeHex(parts[4])); } -// Bound native scrypt memory across concurrent requests and recover abandoned work. +// Bound expensive password work across requests and recover abandoned leases. export async function withPasswordWork(db: D1Database, work: () => Promise): Promise { const id = crypto.randomUUID(), now = Date.now(); const results = await db.batch([ diff --git a/src/hosted/accounts.ts b/src/hosted/accounts.ts index c140936..cc16c75 100644 --- a/src/hosted/accounts.ts +++ b/src/hosted/accounts.ts @@ -1,7 +1,7 @@ import type { HostedEnv } from './types'; import { consumeRate } from './limits'; import { readJson } from './http'; -import { hashPassword, randomToken, tokenHash, verifyPassword, withPasswordWork } from './account-crypto'; +import { hashPassword, randomToken, tokenHash, validPasswordPepper, verifyPassword, withPasswordWork } from './account-crypto'; const DAY = 86_400_000; const COOKIE = '__Host-shotsync'; @@ -101,6 +101,7 @@ export async function handleAccounts(request: Request, env: HostedEnv): Promise< return reply({ ok: true }, 200, cookie('', 0)); } if (!['register', 'login', 'reset-password'].includes(route)) return fail('Not found', 404); + if (!validPasswordPepper(env.PASSWORD_PEPPER)) return fail('Password authentication is temporarily unavailable', 503); const ip = await ipKey(request); if (await limited(env, `ip:${ip}`, 30, 600)) return fail('Try again later', 429); const body = await readJson(request); @@ -113,7 +114,7 @@ export async function handleAccounts(request: Request, env: HostedEnv): Promise< if (await limited(env, 'password-global', 120, 60)) return fail('Authentication is busy. Try again shortly.', 429); const user = await env.DB.prepare('SELECT * FROM users WHERE email=?').bind(email).first(); // Equal-cost password work also for unknown addresses. - const valid = await withPasswordWork(env.DB, async () => user ? verifyPassword(body.password as string, user.password_hash) : (await hashPassword(body.password as string), false)); + const valid = await withPasswordWork(env.DB, async () => user ? verifyPassword(body.password as string, user.password_hash, env.PASSWORD_PEPPER) : (await hashPassword(body.password as string, env.PASSWORD_PEPPER), false)); if (!user || !valid) return fail('Invalid email or password', 401); const token = randomToken(), now = Date.now(); await env.DB.batch([ @@ -133,7 +134,7 @@ export async function handleAccounts(request: Request, env: HostedEnv): Promise< const user = await env.DB.prepare('SELECT id FROM users WHERE email=? AND recovery_hash=?').bind(email, hash).first(); if (!user) return fail('Invalid email or recovery code'); if (await limited(env, 'password-global', 120, 60)) return fail('Authentication is busy. Try again shortly.', 429); - const password = await withPasswordWork(env.DB, () => hashPassword(body.password as string)); + const password = await withPasswordWork(env.DB, () => hashPassword(body.password as string, env.PASSWORD_PEPPER)); const recoveryCode = randomToken(); // Compare-and-swap makes recovery one-time even when requests race. const updated = await env.DB.prepare('UPDATE users SET password_hash=?,recovery_hash=?,auth_version=auth_version+1 WHERE email=? AND recovery_hash=? RETURNING id') @@ -146,7 +147,7 @@ export async function handleAccounts(request: Request, env: HostedEnv): Promise< if (await env.DB.prepare('SELECT id FROM users WHERE email=?').bind(email).first()) return fail('Account already exists. Sign in or use your recovery code.', 409); if ((await env.DB.prepare('SELECT COUNT(*) n FROM users').first<{ n: number }>())!.n >= cap) return fail('Trial is full. Please try again later.', 409); if (await limited(env, 'password-global', 120, 60)) return fail('Authentication is busy. Try again shortly.', 429); - const password = await withPasswordWork(env.DB, () => hashPassword(body.password as string)); + const password = await withPasswordWork(env.DB, () => hashPassword(body.password as string, env.PASSWORD_PEPPER)); const recoveryCode = randomToken(); const inserted = await createUser(env.DB, crypto.randomUUID(), email, password, await tokenHash(recoveryCode), cap); if (!inserted) return fail('Account already exists or trial is full.', 409); diff --git a/src/hosted/types.ts b/src/hosted/types.ts index 7f9557d..be77f31 100644 --- a/src/hosted/types.ts +++ b/src/hosted/types.ts @@ -1,2 +1,2 @@ -export type HostedEnv = HostedBindings & { TURNSTILE_SECRET_KEY: string }; +export type HostedEnv = HostedBindings & { TURNSTILE_SECRET_KEY: string; PASSWORD_PEPPER: string }; export interface Account { id: string; email: string; verified: boolean; via: 'cookie' | 'token' } diff --git a/test/hosted-accounts.test.ts b/test/hosted-accounts.test.ts index b39dc7b..380c942 100644 --- a/test/hosted-accounts.test.ts +++ b/test/hosted-accounts.test.ts @@ -1,4 +1,5 @@ import { env } from 'cloudflare:test'; +import { createHmac, pbkdf2Sync } from 'node:crypto'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { HttpError } from '../src/hosted/http'; import { authenticate, createUser, cleanupAccounts, handleAccounts } from '../src/hosted/accounts'; @@ -11,6 +12,7 @@ import recoverySchema from '../migrations/0003_recovery.sql?raw'; const db = (env as unknown as { DB: D1Database }).DB; const origin = 'https://shotsync.test'; const password = 'a-long-password!'; +const pepper = 'ab'.repeat(32); let bindings: HostedEnv; let passwordHash: string; @@ -34,16 +36,64 @@ async function login(email = 'person@example.com') { beforeEach(async () => { for (const statement of ((schema as string) + (recoverySchema as string)).split(';').filter(s => s.trim())) await db.prepare(statement).run(); vi.spyOn(globalThis, 'fetch').mockImplementation(async () => Response.json({ success: true, hostname: 'shotsync.test' })); - bindings = { ...env, DB: db, PUBLIC_ORIGIN: origin, TURNSTILE_SECRET_KEY: 'test-secret', REGISTRATION_LIMIT: '100' } as unknown as HostedEnv; - passwordHash = await hashPassword(password); + bindings = { ...env, DB: db, PUBLIC_ORIGIN: origin, TURNSTILE_SECRET_KEY: 'test-secret', PASSWORD_PEPPER: pepper, REGISTRATION_LIMIT: '100' } as unknown as HostedEnv; + passwordHash = await hashPassword(password, pepper); }); afterEach(() => vi.restoreAllMocks()); describe('hosted accounts with real D1', () => { - it('uses a salted modern scrypt hash in the Worker runtime', async () => { - expect(passwordHash).toMatch(/^scrypt:16384:8:5:/); - expect(await verifyPassword(password, passwordHash)).toBe(true); - expect(await verifyPassword('wrong-password', passwordHash)).toBe(false); - expect(await hashPassword(password)).not.toBe(passwordHash); + it('uses a salted and peppered PBKDF2 verifier in the Worker runtime', async () => { + expect(passwordHash).toMatch(/^pbkdf2-sha256:v1:100000:[a-f0-9]{64}:[a-f0-9]{64}$/); + expect(await verifyPassword(password, passwordHash, pepper)).toBe(true); + expect(await verifyPassword('wrong-password', passwordHash, pepper)).toBe(false); + expect(await hashPassword(password, pepper)).not.toBe(passwordHash); + }); + it('matches an independent PBKDF2/HMAC calculation and never stores the bare verifier', async () => { + const salt = passwordHash.split(':')[3]; + const bare = pbkdf2Sync(password, Buffer.from(salt, 'hex'), 100_000, 32, 'sha256'); + const expected = createHmac('sha256', Buffer.from(pepper, 'hex')).update(bare).digest('hex'); + expect(passwordHash.split(':')[4]).toBe(expected); + expect(passwordHash).not.toContain(Buffer.from(bare).toString('hex')); + expect(await verifyPassword(password, passwordHash, 'cd'.repeat(32))).toBe(false); + }); + it('rejects missing or malformed pepper before creating or authenticating accounts', async () => { + await seed(); + for (const bad of ['', 'short', 'gg'.repeat(32), 'ab'.repeat(31)]) { + await expect(hashPassword(password, bad)).rejects.toMatchObject({ status: 503 }); + await expect(verifyPassword(password, passwordHash, bad)).rejects.toMatchObject({ status: 503 }); + bindings.PASSWORD_PEPPER = bad; + for (const route of ['login', 'register', 'reset-password']) { + expect((await call(route, { email: 'person@example.com', password, turnstileToken: 'captcha' })).status).toBe(503); + } + } + expect(await db.prepare('SELECT hash FROM sessions').first()).toBeNull(); + expect((await db.prepare('SELECT COUNT(*) n FROM users').first())?.n).toBe(1); + }); + it('rejects legacy hashes, unsupported versions, changed parameters and malformed values', async () => { + const salt = passwordHash.split(':')[3], digest = passwordHash.split(':')[4]; + for (const stored of [ + `scrypt:16384:8:5:${salt}:${digest}`, + passwordHash.replace(':v1:', ':v2:'), + passwordHash.replace(':100000:', ':1:'), + passwordHash.replace(salt, salt.slice(2)), + passwordHash.replace(digest, 'gg'.repeat(32)), + `${passwordHash}:extra`, '', + ]) expect(await verifyPassword(password, stored, pepper)).toBe(false); + }); + it('performs password work for unknown addresses and never issues a session', async () => { + const derive = vi.spyOn(crypto.subtle, 'deriveBits'); + expect((await call('login', { email: 'unknown@example.com', password })).status).toBe(401); + expect(derive).toHaveBeenCalledOnce(); + expect(derive.mock.calls[0][0]).toMatchObject({ name: 'PBKDF2', hash: 'SHA-256', iterations: 100_000 }); + expect(await db.prepare('SELECT hash FROM sessions').first()).toBeNull(); + }); + it('allows recovery from a legacy verifier without accepting the old hash', async () => { + const id = await seed(), recoveryCode = randomToken(); + await db.prepare('UPDATE users SET password_hash=?,recovery_hash=? WHERE id=?') + .bind(`scrypt:16384:8:5:${'ab'.repeat(32)}:${'cd'.repeat(32)}`, await tokenHash(recoveryCode), id).run(); + expect((await call('login', { email: 'person@example.com', password })).status).toBe(401); + expect((await call('reset-password', { email: 'person@example.com', recoveryCode, password, turnstileToken: 'captcha' })).status).toBe(200); + expect((await call('login', { email: 'person@example.com', password })).status).toBe(200); + expect((await db.prepare('SELECT password_hash FROM users WHERE id=?').bind(id).first())?.password_hash).toMatch(/^pbkdf2-sha256:v1:100000:/); }); it('rejects cross-origin and form mutations', async () => { expect((await call('login', {}, { Origin: 'https://evil.test' })).status).toBe(403); diff --git a/test/hosted-files.test.ts b/test/hosted-files.test.ts index 3e7be07..6d2d2ec 100644 --- a/test/hosted-files.test.ts +++ b/test/hosted-files.test.ts @@ -5,7 +5,7 @@ import { consumeRate, LIMITS } from '../src/hosted/limits'; import type { HostedEnv, Account } from '../src/hosted/types'; import worker from '../src/hosted/index'; const bindings = env as unknown as HostedEnv & { TEST_MIGRATIONS: D1Migration[] }; -const hosted = { ...bindings, PUBLIC_ORIGIN: 'https://shotsync.test', UPLOADS_ENABLED: '1' }; +const hosted = { ...bindings, PASSWORD_PEPPER: 'ab'.repeat(32), PUBLIC_ORIGIN: 'https://shotsync.test', UPLOADS_ENABLED: '1' }; const user: Account = { id: 'u1', email: 'one@example.com', verified: false, via: 'cookie' }; const other: Account = { ...user, id: 'u2', email: 'two@example.com' }; const origin = hosted.PUBLIC_ORIGIN; diff --git a/wrangler.hosted.jsonc b/wrangler.hosted.jsonc index 2d83735..9b3b2c3 100644 --- a/wrangler.hosted.jsonc +++ b/wrangler.hosted.jsonc @@ -9,9 +9,6 @@ "observability": { "enabled": true }, - "limits": { - "cpu_ms": 1000 - }, "vars": { "PUBLIC_ORIGIN": "https://shotsync-hosted.defiabell.workers.dev", "TURNSTILE_SITE_KEY": "0x4AAAAAAE8wLBNWAJtUWmVb",