bc40f634fb77
resolve PB-26-930 through PB-26-934 at Browser 5.2.52
- time
- 2026-09-12 03:24 UTC · 1 month ago
- machine
- koh2
- agent
- camel-stream/auto
- session
- resolve 2026-09-12 audit
- parent
- 6aa6595bc1de
↓ peach-browser-bc40f634.tar.gzatlas at this save
files changed11 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/aes.test.ts+8 −0src/background/aes.ts+22 −16src/background/license.ts+24 −48src/background/license.verification.test.ts+18 −0src/background/locked-sync-spool.test.ts+4 −6src/background/locked-sync-spool.ts+20 −29src/background/sync-vault-adapter.ts+3 −6src/background/vault.ts+54 −28manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "5.2.51",4+ "version": "5.2.52",55 "description": "Zero-knowledge password manager",66 "browser_specific_settings": {77 "gecko": {manifest.json+1 −1
@@ -2,7 +2,7 @@22 "manifest_version": 3,33 "minimum_chrome_version": "116",44 "name": "Peach Password Manager",5- "version": "5.2.51",5+ "version": "5.2.52",66 "description": "Zero-knowledge password manager",77 "action": {88 "default_popup": "src/popup/index.html",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "5.2.51",3+ "version": "5.2.52",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/background/aes.test.ts+8 −0
@@ -60,4 +60,12 @@6060 expect(encrypted.tag).toHaveLength(32);6161 expect(encrypted.ciphertext.length / 2).toBe(new TextEncoder().encode(plaintext).length);6262 });63+ it("rejects malformed hex before decrypting", async () => {64+ const key = new Uint8Array(32);65+ await expect(decrypt(key, "abc", "00", "00".repeat(16))).rejects.toThrow(/nonce/i);66+ await expect(decrypt(key, "00".repeat(12), "0", "00".repeat(16))).rejects.toThrow(/ciphertext/i);67+ await expect(decrypt(key, "00".repeat(12), "gg", "00".repeat(16))).rejects.toThrow(/ciphertext/i);68+ await expect(decrypt(key, "00".repeat(12), "", "00".repeat(16))).rejects.toThrow(/ciphertext/i);69+ await expect(decrypt(key, "00".repeat(12), "00", "00".repeat(15))).rejects.toThrow(/tag/i);70+ });6371 });src/background/aes.ts+22 −16
@@ -24,11 +24,19 @@2424 .join("");2525 }2626 27-function unhex(value: string): Uint8Array {28- const out = new Uint8Array(value.length / 2);29- for (let i = 0; i < value.length; i += 2) {30- out[i / 2] = parseInt(value.slice(i, i + 2), 16);27+const MAX_CIPHERTEXT_BYTES = 8 * 1024 * 1024;28+29+function unhex(value: string, field: string, options: { empty?: boolean; exactBytes?: number } = {}): Uint8Array {30+ if (typeof value !== "string" || value.length % 2 !== 0 || !/^[0-9a-f]*$/i.test(value)) {31+ throw new Error(`invalid ${field}`);3132 }33+ if (options.exactBytes !== undefined && value.length !== options.exactBytes * 2) {34+ throw new Error(`invalid ${field}`);35+ }36+ if (!options.empty && value.length === 0) throw new Error(`invalid ${field}`);37+ if (value.length / 2 > MAX_CIPHERTEXT_BYTES) throw new Error(`${field} too large`);38+ const out = new Uint8Array(value.length / 2);39+ for (let i = 0; i < value.length; i += 2) out[i / 2] = Number.parseInt(value.slice(i, i + 2), 16);3240 return out;3341 }3442 @@ -99,28 +107,26 @@99107 tag: string,100108 aad: Uint8Array,101109 ): Promise<Uint8Array> {110+ const nonceBytes = unhex(nonce, "nonce", { exactBytes: 12 });111+ const cipherBytes = unhex(ciphertext, "ciphertext");112+ const tagBytes = unhex(tag, "tag", { exactBytes: 16 });113+ const combined = new Uint8Array(cipherBytes.length + tagBytes.length);114+ combined.set(cipherBytes);115+ combined.set(tagBytes, cipherBytes.length);116+102117 const key = await crypto.subtle.importKey(103118 "raw",104119 keyBytes.buffer as ArrayBuffer,105120 { name: "AES-GCM" },106121 false,107- ["decrypt"]122+ ["decrypt"],108123 );109124 110- const cipherBytes = unhex(ciphertext);111- const tagBytes = unhex(tag);112- const combined = new Uint8Array(cipherBytes.length + tagBytes.length);113- combined.set(cipherBytes);114- combined.set(tagBytes, cipherBytes.length);115-116125 const raw = await crypto.subtle.decrypt(117126 {118127 name: "AES-GCM",119- iv: unhex(nonce).buffer as ArrayBuffer,120- additionalData: aad.buffer.slice(121- aad.byteOffset,122- aad.byteOffset + aad.byteLength,123- ) as ArrayBuffer,128+ iv: nonceBytes.buffer as ArrayBuffer,129+ additionalData: aad.buffer.slice(aad.byteOffset, aad.byteOffset + aad.byteLength) as ArrayBuffer,124130 tagLength: 128,125131 },126132 key,src/background/license.ts+24 −48
@@ -52,76 +52,52 @@5252 if (!current.includes(identifier)) current.push(identifier);5353 await chrome.storage.local.set({ [LICENSE_REVOCATIONS_KEY]: current });5454 }55+// Revocations are profile-local. No issuer update channel is implemented; a56+// user must activate a newly issued license on each installation.5557 56-async function importPublicKey(): Promise<CryptoKey> {57- const keyData = hexToBytes(PUBLIC_KEY_HEX);58- return (crypto.subtle as any).importKey(58+async function importPublicKey(publicKeyHex = PUBLIC_KEY_HEX): Promise<CryptoKey> {59+ const keyData = hexToBytes(publicKeyHex);60+ return crypto.subtle.importKey(5961 "raw",60- keyData,62+ keyData.buffer as ArrayBuffer,6163 { name: "Ed25519" },6264 true,63- ["verify"]65+ ["verify"],6466 );6567 }6668 6769 function hexToBytes(hex: string): Uint8Array {68- const bytes = new Uint8Array(hex.length / 2);69- for (let i = 0; i < hex.length; i += 2) {70- bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);71- }70+ if (!/^[0-9a-f]{64}$/i.test(hex)) throw new Error("invalid public key");71+ const bytes = new Uint8Array(32);72+ for (let i = 0; i < bytes.length; i += 1) bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);7273 return bytes;7374 }7475 75-export async function validateLicense(keyString: string): Promise<LicenseInfo> {76- if (!keyString || typeof keyString !== "string") {77- return { valid: false, plan: "free" };78- }79-76+export async function validateLicense(keyString: string, publicKeyHex = PUBLIC_KEY_HEX): Promise<LicenseInfo> {77+ if (!keyString || typeof keyString !== "string") return { valid: false, plan: "free" };8078 const trimmed = keyString.trim();81- if (!trimmed.startsWith(LICENSE_PREFIX)) {82- return { valid: false, plan: "free" };83- }79+ if (!trimmed.startsWith(LICENSE_PREFIX)) return { valid: false, plan: "free" };8480 8581 try {86- const withoutPrefix = trimmed.slice(LICENSE_PREFIX.length);87- const groups = withoutPrefix.split("-");88- if (groups.length !== 5) {89- return { valid: false, plan: "free" };90- }82+ const groups = trimmed.slice(LICENSE_PREFIX.length).split("-");83+ if (groups.length !== 5) return { valid: false, plan: "free" };9184 const decoded = base58Decode(fromBase58Groups(groups));92- if (decoded.length !== 76) {93- return { valid: false, plan: "free" };94- }95-85+ if (decoded.length !== 76) return { valid: false, plan: "free" };9686 const payload = decoded.slice(0, LICENSE_PAYLOAD_LENGTH);97- const signature = decoded.slice(LICENSE_PAYLOAD_LENGTH, 76);98-87+ const signature = decoded.slice(LICENSE_PAYLOAD_LENGTH);9988 if (payload[0] !== 0x01 || payload[1] !== 0x01 || payload[2] !== 0x01) {10089 return { valid: false, plan: "free" };10190 }102-10391 const issuedTimestamp = new DataView(payload.buffer, payload.byteOffset + 3, 4).getUint32(0, true);104- const issuedDate = new Date(issuedTimestamp * 86400 * 1000);10592 const identifier = new DataView(payload.buffer, payload.byteOffset + 7, 4).getUint32(0, true);106-107- const publicKey = await importPublicKey();108- const isValid = await (crypto.subtle as any).verify(109- "Ed25519",110- publicKey,111- signature,112- payload113- );114-115- if (!isValid) {116- return { valid: false, plan: "free" };117- }118-93+ const publicKey = await importPublicKey(publicKeyHex);94+ const isValid = await crypto.subtle.verify("Ed25519", publicKey, signature, payload);95+ if (!isValid) return { valid: false, plan: "free" };11996 const revocations = await chrome.storage.local.get(LICENSE_REVOCATIONS_KEY) as Record<string, unknown>;120- const revoked = Array.isArray(revocations[LICENSE_REVOCATIONS_KEY]) &&121- revocations[LICENSE_REVOCATIONS_KEY].some((value) => value === identifier);122- if (revoked) return { valid: false, plan: "free", identifier };123-124- return { valid: true, plan: "pro", issued: issuedDate, identifier };97+ if (Array.isArray(revocations[LICENSE_REVOCATIONS_KEY]) && revocations[LICENSE_REVOCATIONS_KEY].some((value) => value === identifier)) {98+ return { valid: false, plan: "free", identifier };99+ }100+ return { valid: true, plan: "pro", issued: new Date(issuedTimestamp * 86400 * 1000), identifier };125101 } catch {126102 return { valid: false, plan: "free" };127103 }src/background/license.verification.test.ts+18 −0
@@ -247,4 +247,22 @@247247 const result = await validateLicense("PPK-AAAA-BBBB-CCCC-DDDD");248248 expect(result.valid).toBe(false);249249 });250+ it("uses the production verifier with a public-key vector and honors local revocation", async () => {251+ const local: Record<string, unknown> = {};252+ vi.stubGlobal("chrome", {253+ storage: {254+ local: {255+ get: vi.fn(async (key: string) => ({ [key]: local[key] })),256+ set: vi.fn(async (next: Record<string, unknown>) => Object.assign(local, next)),257+ },258+ },259+ });260+ const license = await buildTestLicense(testPrivKey);261+ const valid = await validateLicense(license, testPubKeyHex);262+ expect(valid).toMatchObject({ valid: true, plan: "pro", identifier: 0 });263+ const { revokeLicenseIdentifier } = await import("./license");264+ await revokeLicenseIdentifier(0);265+ await expect(validateLicense(license, testPubKeyHex)).resolves.toMatchObject({ valid: false, plan: "free", identifier: 0 });266+ vi.unstubAllGlobals();267+ });250268 });src/background/locked-sync-spool.test.ts+4 −6
@@ -135,10 +135,7 @@135135 expect(local.peach_locked_sync_spool_v1).toBeUndefined();136136 });137137 138- // chrome.storage.session is memory-backed but outlives the MV3 service139- // worker, which is what makes it the right home for the spool: locked sync140- // keeps working across worker death without the key ever reaching disk.141- it("the spool survives a service-worker restart but not a new browser session", async () => {138+ it("fails closed after a worker restart because the private key is not persisted", async () => {142139 const vault = await import("./vault");143140 await vault.createVault("correct horse battery staple");144141 const { refreshLockedSyncSpool } = await import("./sync-vault-adapter");@@ -147,9 +144,10 @@147144 148145 vi.resetModules();149146 const { getLockedSyncIdentity } = await import("./locked-sync-spool");150- expect(await getLockedSyncIdentity()).not.toBeNull();147+ expect(await getLockedSyncIdentity()).toBeNull();148+ expect(JSON.stringify(session.peach_locked_sync_spool_v1)).not.toContain("PrivateKey");149+ expect(JSON.stringify(session.peach_locked_sync_spool_v1)).not.toContain("longTermPrivateKey");151150 152- // Browser restart: the session area is cleared for us.153151 for (const key of Object.keys(session)) delete session[key];154152 vi.resetModules();155153 const afterRestart = await import("./locked-sync-spool");src/background/locked-sync-spool.ts+20 −29
@@ -8,19 +8,8 @@88 const STORAGE_KEY = "peach_locked_sync_spool_v1";99 1010 /**11- * The spool lives in `chrome.storage.session`, never `chrome.storage.local`.12- *13- * PB-26-104: locked sync needs the device's long-term Noise static while the14- * vault is locked, which is exactly when no material exists to wrap it with —15- * anything available while locked is by definition at rest. Session storage is16- * memory-backed: the key never reaches disk, surviving the MV3 service-worker17- * restarts that a plain in-memory cache would not, and dying with the browser18- * session. That is the same accepted profile as the master-password-derived19- * session key (M-1).20- *21- * Session storage is quota-bound, so a vault too large to spool simply loses22- * locked sync for that browser session: the write failure is caught by23- * vault.refreshLockedSyncState and lock() still completes.11+ * Locked sync metadata lives in session storage, but the long-term private key12+ * stays only in this worker's heap. A worker restart fails closed until unlock.2413 */2514 2615 interface CachedEntry {@@ -38,13 +27,19 @@3827 deviceType: DeviceType;3928 localVersion: number;4029 longTermPublicKeyHex: string;41- longTermPrivateKeyHex: string;4230 entries: Record<string, CachedEntry>;4331 tombstones: Record<string, number>;4432 inboxEntries: Record<string, StagedEntry>;4533 inboxTombstones: Record<string, number>;4634 }4735 36+let lockedPrivateKey: Uint8Array | null = null;37+38+export function setLockedSyncPrivateKey(value: Uint8Array): void {39+ lockedPrivateKey?.fill(0);40+ lockedPrivateKey = new Uint8Array(value);41+}42+4843 let spoolMutation: Promise<void> = Promise.resolve();4944 5045 export async function replaceLockedSyncSpool(@@ -62,23 +57,16 @@6257 6358 export async function getLockedSyncIdentity(): Promise<SyncPeerIdentity | null> {6459 const snapshot = await loadSpool();65- if (!snapshot) return null;60+ if (!snapshot || !lockedPrivateKey) return null;6661 return {6762 deviceId: snapshot.deviceId,6863 deviceName: snapshot.deviceName,6964 deviceType: snapshot.deviceType,7065 longTermPublicKeyHex: snapshot.longTermPublicKeyHex,71- longTermPrivateKey: hexToBytes(snapshot.longTermPrivateKeyHex),66+ longTermPrivateKey: new Uint8Array(lockedPrivateKey),7267 };7368 }7469 75-function hexToBytes(value: string): Uint8Array {76- const out = new Uint8Array(value.length / 2);77- for (let i = 0; i < out.length; i += 1) {78- out[i] = Number.parseInt(value.slice(i * 2, i * 2 + 2), 16);79- }80- return out;81-}8270 8371 export async function drainLockedSyncInbox(8472 live: SyncVaultAdapter,@@ -217,15 +205,18 @@217205 if (!value || typeof value !== "object") return false;218206 const candidate = value as Partial<LockedSyncSpool>;219207 return candidate.version === 1 &&220- typeof candidate.longTermPrivateKeyHex === "string" &&221- /^[0-9a-f]{64}$/.test(candidate.longTermPrivateKeyHex) &&222208 typeof candidate.vaultId === "string" && /^[0-9a-f]{64}$/.test(candidate.vaultId) &&223- typeof candidate.deviceId === "string" && candidate.deviceId.length > 0 &&224- typeof candidate.deviceName === "string" &&209+ typeof candidate.deviceId === "string" && candidate.deviceId.length > 0 && candidate.deviceId.length <= 128 &&210+ typeof candidate.deviceName === "string" && candidate.deviceName.length <= 128 &&211+ (candidate.deviceType === "extension" || candidate.deviceType === "android" || candidate.deviceType === "ios") &&225212 typeof candidate.longTermPublicKeyHex === "string" && /^[0-9a-f]{64}$/.test(candidate.longTermPublicKeyHex) &&226- typeof candidate.localVersion === "number" &&213+ typeof candidate.localVersion === "number" && Number.isSafeInteger(candidate.localVersion) &&227214 isRecord(candidate.entries) && isRecord(candidate.tombstones) &&228- isRecord(candidate.inboxEntries) && isRecord(candidate.inboxTombstones);215+ isRecord(candidate.inboxEntries) && isRecord(candidate.inboxTombstones) &&216+ Object.keys(candidate.entries).length <= SYNC_LIMITS.MAX_ENTRY_COUNT &&217+ Object.keys(candidate.tombstones).length <= SYNC_LIMITS.MAX_TOMBSTONE_COUNT &&218+ Object.keys(candidate.inboxEntries).length <= SYNC_LIMITS.MAX_ENTRY_COUNT &&219+ Object.keys(candidate.inboxTombstones).length <= SYNC_LIMITS.MAX_TOMBSTONE_COUNT;229220 }230221 231222 function isRecord(value: unknown): value is Record<string, unknown> {src/background/sync-vault-adapter.ts+3 −6
@@ -75,7 +75,7 @@7575 type PairedDevice,7676 type SignedDeviceManifest,7777 } from "../shared/sync-types";78-import { replaceLockedSyncSpool, drainLockedSyncInbox } from "./locked-sync-spool";78+import { replaceLockedSyncSpool, drainLockedSyncInbox, setLockedSyncPrivateKey } from "./locked-sync-spool";7979 8080 // Each entry uses a unique AES-GCM key: HKDF(masterKey, info = entryId + "\x00" + "sync-entry-v1").8181 // The info binding prevents a ciphertext from one entry slot being replayed into another.@@ -249,11 +249,8 @@249249 };250250 }251251 const tombstones = getTombstoneManifest();252- // PB-26-104: the private key rides along because locked sync needs it, but253- // the spool is written to chrome.storage.session — never to disk. See254- // locked-sync-spool.ts for why session storage is the only home that255- // satisfies both constraints.256- const { longTermPrivateKeyBytes: _privateKeyBytes, ...storedIdentity } = identity;252+ setLockedSyncPrivateKey(identity.longTermPrivateKeyBytes);253+ const { longTermPrivateKeyHex: _privateKeyHex, longTermPrivateKeyBytes: _privateKeyBytes, ...storedIdentity } = identity;257254 await replaceLockedSyncSpool({258255 ...storedIdentity,259256 localVersion: await getVaultVersion(),src/background/vault.ts+54 −28
@@ -1260,7 +1260,44 @@12601260 typeof r.tag === "string"12611261 );12621262 }1263+const MAX_HISTORY_BLOB_BYTES = 1_024 * 1_024;12631264 1265+function hasValidHistorySlot(value: unknown): value is StoredVault {1266+ if (!hasValidVaultSchema(value)) return false;1267+ return /^[0-9a-f]{32}$/i.test(value.salt) &&1268+ /^[0-9a-f]{24}$/i.test(value.nonce) &&1269+ /^[0-9a-f]+$/i.test(value.ciphertext) &&1270+ value.ciphertext.length % 2 === 0 &&1271+ value.ciphertext.length / 2 <= MAX_HISTORY_BLOB_BYTES &&1272+ /^[0-9a-f]{32}$/i.test(value.tag);1273+}1274+1275+function parseHistorySlot(value: string): StoredVault | null {1276+ try {1277+ const parsed: unknown = JSON.parse(value);1278+ return hasValidHistorySlot(parsed) ? parsed : null;1279+ } catch {1280+ return null;1281+ }1282+}1283+1284+function isHistoryRecord(value: unknown): value is VaultSnapshot {1285+ if (typeof value !== "object" || value === null) return false;1286+ const record = value as Record<string, unknown>;1287+ return typeof record.id === "string" && record.id.length > 0 && record.id.length <= 128 &&1288+ typeof record.savedAt === "number" && Number.isFinite(record.savedAt) &&1289+ typeof record.reason === "string" &&1290+ typeof record.vault_a === "string" && record.vault_a.length <= MAX_HISTORY_BLOB_BYTES * 2 &&1291+ typeof record.vault_b === "string" && record.vault_b.length <= MAX_HISTORY_BLOB_BYTES * 2 &&1292+ (record.entryCount === null || typeof record.entryCount === "number");1293+}1294+1295+function sanitizeHistory(value: unknown): VaultSnapshot[] {1296+ if (typeof value !== "object" || value === null) return [];1297+ if (!("snapshots" in value) || !Array.isArray(value.snapshots)) return [];1298+ return value.snapshots.filter(isHistoryRecord).slice(0, HISTORY_MAX);1299+}1300+12641301 export type TryDecryptResult =12651302 | { status: "ok"; payload: DecryptedPayload }12661303 | { status: "wrong_key" }@@ -3411,56 +3448,45 @@34113448 34123449 /** Read all snapshots, newest first, for the Settings vault history view. */34133450 export async function getVaultHistory(): Promise<VaultSnapshot[]> {3414- // The invariant lives here as well as at the message handlers, so it does not3415- // depend on every caller remembering it — mirroring how takeVaultSnapshot3416- // refuses to record while in decoy mode (PB-26-915).34173451 if (vaultMode === "decoy") return [];34183452 const raw = await chrome.storage.local.get(HISTORY_KEY);3419- const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3420- if (!history || !Array.isArray(history.snapshots)) return [];3421- return history.snapshots;3453+ const value = (raw as Record<string, unknown>)[HISTORY_KEY];3454+ return sanitizeHistory(value);34223455 }34233456 34243457 /**34253458 * Replace the on-disk vault with the chosen snapshot's blobs and lock the3426- * vault. The current state is itself snapshotted with reason 'restore' first3427- * so the restore is reversible. After this returns, the user must unlock3428- * with the password that matches the restored blob.3459+ * vault. The current state is itself snapshotted before replacement.34293460 */34303461 export async function restoreVaultSnapshot(34313462 snapshotId: string,34323463 ): Promise<{ ok: true } | { ok: false; error: string }> {3433- // A decoy session holds no real-mode authority: refusing here means a coerced3434- // user cannot be made to roll the real vault back to an earlier snapshot3435- // (PB-26-915).34363464 if (vaultMode === "decoy") return { ok: false, error: "vault_locked" };34373465 const history = await getVaultHistory();34383466 const snapshot = history.find((s) => s.id === snapshotId);34393467 if (!snapshot) return { ok: false, error: "snapshot_not_found" };34403468 3441- // Snapshot the about-to-be-replaced current state so the restore is3442- // reversible. takeVaultSnapshot is no-op when the most recent entry is3443- // byte-identical, so a restore-of-a-restore stays clean.3444- await takeVaultSnapshot("restore");3469+ const restoredA = parseHistorySlot(snapshot.vault_a);3470+ const restoredB = parseHistorySlot(snapshot.vault_b);3471+ if (!restoredA || !restoredB) return { ok: false, error: "snapshot_corrupt" };34453472 3446- let restoredA: StoredVault;3447- let restoredB: StoredVault;3473+ await takeVaultSnapshot("restore");3474+ const current = await chrome.storage.local.get([SLOT_A, SLOT_B]);3475+ const previousA = (current as Record<string, unknown>)[SLOT_A];3476+ const previousB = (current as Record<string, unknown>)[SLOT_B];34483477 try {3449- restoredA = JSON.parse(snapshot.vault_a) as StoredVault;3450- restoredB = JSON.parse(snapshot.vault_b) as StoredVault;3478+ await chrome.storage.local.set({ [SLOT_A]: restoredA, [SLOT_B]: restoredB });3479+ await stampLocalVaultMutation();34513480 } catch {3481+ try {3482+ await chrome.storage.local.set({ [SLOT_A]: previousA, [SLOT_B]: previousB });3483+ } catch {3484+ // Preserve the restore failure if storage cannot complete the rollback.3485+ }34523486 return { ok: false, error: "snapshot_corrupt" };34533487 }34543488 3455- await writeSlot(SLOT_A, restoredA);3456- await writeSlot(SLOT_B, restoredB);3457- await stampLocalVaultMutation();3458-3459- // Lock so the user must prove they can decrypt the restored blob. If they3460- // restored a pre-password-change snapshot they discover it immediately at3461- // the lock screen rather than silently holding an inaccessible vault.34623489 await lock();3463-34643490 return { ok: true };34653491 }34663492