Made the Find My export CLI buildable against published fork dependencies, fixed export filename collisions, improved Apple ID authentication and reuse, investigated the OpenBubbles upstream state, fixed the iCloud Keychain and PCS failures observed during live testing, fixed the naming-record association, split the resulting dependency changes into publishable fork commits, added interactive escrow bottle deletion, replaced the hardcoded device identity with a persistent profile-centered workspace, and added dual-format export (plist + JSON) for direct FindMy.py compatibility.
- Installed the protobuf compiler required by the Rust dependency graph.
- Used local checkouts under
vendor/while diagnosing and separating changes. - Fixed the nested
clearadisubmodule URL to use HTTPS instead of an inaccessible SSH checkout. - Published the dependency changes to dedicated forks.
- Changed the root
Cargo.tomlback from local paths to Git dependencies. - Built the release binary at
target/release/export-findmy. - Dependency crates emit numerous pre-existing warnings, but builds and tests complete successfully.
The vendor/ directory is now only a local development checkout. It is about
655 MB, contains nested Git repositories and build artifacts, and must not be
committed to export-findmy.
AirTags and other items can share the same display name, which previously made later exports overwrite earlier plist files.
The exporter now:
- Sanitizes the display name for use as a filename.
- Includes the sanitized hardware model after the display name so exported accessories are easier to identify.
- Appends a stable item identifier.
- Adds a deterministic collision suffix if the resulting name is still in use.
- Includes tests for duplicate names and filename sanitization.
The filename format is now
<name>_<model>_<stable-identifier>.<plist|json>. Missing model values use the
unknown-model fallback.
The initial AuthSrp failure occurred immediately after Apple displayed the
2FA prompt because the authentication state machine treated trusted-device 2FA
and SMS 2FA as the same transition. It requested a trusted-device prompt and
then immediately attempted SMS verification with a hardcoded phone identifier,
before the CLI could ask for the verification code.
The vendored icloud-auth client now:
- Handles
NeedsDevice2FAseparately and prompts for the trusted-device code. - Handles
NeedsSMS2FAthrough the SMS flow. - Retrieves the actual trusted phone identifier from Apple's auth metadata
instead of assuming identifier
1. - Reports HTTP failures with operation-specific context.
The automatic 2FA flow handles trusted-device and SMS modes but does not yet
expose a choice to the user. A staged login-state-machine API in icloud-auth
would let the CLI present available methods (trusted device, multiple phone
numbers) and accept the user's selection before requesting a challenge. This is
implemented — see the "Interactive Apple 2FA method selection" section below.
Useful authentication logging:
RUST_LOG=icloud_auth=debug,omnisette=debug ./target/release/export-findmytrace logging should be avoided for normal debugging because authentication
responses can contain sensitive account metadata.
The CLI now supports a plaintext plist cache so successful Apple account authentication can be reused instead of requiring password and 2FA on every run.
The cache contains:
- Apple ID username.
- SPD account metadata.
- GSA, HB, and PET tokens with expiration timestamps.
- The SHA-256 password hash required by the existing token refresh path.
It does not store the raw password or device passcode.
Behavior:
- Default path:
auth_cache.plist. - Cache files are created with Unix mode
0600. - The default cache file lives under the active profile directory and is
covered by
.local/.gitignore. - Cached authentication is validated using the MobileMe delegate request.
- Rejected or revoked cached authentication is deleted immediately, before falling back to interactive login. This prevents the rejected cache from being retried if the fresh login fails or is interrupted.
- A successful fresh login writes a replacement cache.
- A cache belonging to a different Apple ID is ignored.
Options:
--auth-cache <path> Use a custom plaintext cache path
--no-auth-cache Disable cache reads and writes
--clear-auth-cache Delete the cache before authentication
Reviewed OpenBubbles/rustpush issue #18 and compared the vendored
thisiscam/rustpush findmy-export-support branch with current upstream
OpenBubbles/rustpush.
Findings:
- Issue #18 confirms that owned-item Find My export needs GSA/2FA, Cuttlefish/Octagon trust, PCS, and CloudKit.
- Remote anisette is sufficient for this path.
- Albert activation is not required for exporting the account owner's items. APS activation is relevant to sharing and iMessage-related paths.
- The fork commit inspected was three upstream commits behind, but both trees
referenced the same
apple-private-apiscommit foricloud-auth. - Therefore, upstream did not already contain a fix for the observed 2FA state machine defect.
- Upstream had a relevant MobileMe delegate parsing fix. That narrow fix was ported.
- Broader upstream authentication changes switched to V2 authentication and required validation data. They were not ported because this CLI's headless device configuration currently supplies no validation data and the change could break authentication.
The MobileMe parser now:
- Accepts a missing config dictionary with
#[serde(default)]. - Preserves the full service-data dictionary as the MobileMe configuration.
- Searches recursively for
escrowProxyUrlto tolerate Apple response layout differences.
This removes the previous accidental empty configuration and reduces reliance on the hardcoded escrow proxy fallback.
The original failure was:
No escrow bottles found. Make sure you have another trusted device.
Diagnostics were added to distinguish these cases:
- Apple returned no escrow metadata.
- Apple returned no viable Cuttlefish bottles.
- A bottle had no matching metadata record.
- Matching metadata existed but failed schema validation.
Information-level logging reports record and bottle counts. Debug logging reports the schema error and top-level plist key/type shape without logging plist values or secrets.
Useful escrow logging:
RUST_LOG=rustpush::icloud::keychain=info ./target/release/export-findmy
RUST_LOG=rustpush::icloud::keychain=debug ./target/release/export-findmyThe account returned:
Escrow lookup returned 27 metadata record(s) and 4 viable Cuttlefish bottle(s)
Discarded 0 bottle(s) without matching escrow metadata and 4 bottle(s) with invalid metadata
Debug output identified the exact incompatibility:
Serde("missing field `passcodeGeneration`")
All four otherwise matching records omitted passcodeGeneration. The records
contained the expected bottle, client metadata, SPKI, build, and timestamp
fields, so this was an Apple schema variation rather than an absence of trusted
devices or escrow bottles.
passcodeGeneration now uses #[serde(default)], producing 0 when Apple
omits it.
A broader schema-compatible fallback was briefly implemented during diagnosis.
After the exact variation was identified, it was removed. The final parser is
strict for the known escrow schema, with only passcodeGeneration treated as
optional. Future schema mismatches remain visible in debug logs and are
rejected instead of being silently accepted.
The user-facing empty-bottle error now also points to the relevant RUST_LOG
target.
The bottle chooser originally displayed only EscrowMetadata.serial, which
made accounts with several viable bottles difficult to use. Those identifiers
are device hardware serial numbers.
The chooser now also reads the following optional fields from
ClientMetadata:
device_namedevice_model_classdevice_model
Each choice displays the available device name/model followed by its serial, OS build, and escrow timestamp. If Apple omits the client metadata fields, the serial, build, and timestamp remain available for cross-referencing against the trusted devices listed in Apple Account settings.
Live output confirmed that device_model can contain Apple's human-readable
marketing name, for example iPad Pro (11-inch) (2nd generation), rather than
a hardware identifier such as iPad8,9.
After the escrow parser fix, a live run successfully joined the trust circle
and then panicked while syncing keychain data for the Find My CloudKit fetch.
The repeated AAD v2 / decrypted data messages came from
CuttlefishEncItem::decrypt, not from the final BeaconStore record decoder.
The vendored upstream implementation used panic-prone operations in that path:
- authenticated SIV decryption used
.unwrap() - malformed ISO/IEC 7816-4 padding called
panic!("Bad padding!") - short ciphertext could panic while slicing the IV
- decrypted keychain payloads were logged in hexadecimal at
INFO
The upstream OpenBubbles revision checked during diagnosis still has the same panic-prone implementation, so there was no newer fix to port.
The local implementation now:
- validates ciphertext and record-key lengths
- converts authenticated-decryption and padding failures into a contextual
KeychainItemDecryptError - logs skipped item identifiers and errors instead of terminating the process
- moves AAD construction messages to
DEBUG - no longer logs decrypted keychain payload bytes
The successful join was committed by Apple before the later panic, which
explains why the next escrow lookup contained a fifth bottle. The old CLI kept
KeychainClientState only in memory, so every process restart had to create a
new peer and escrow bottle even after a successful join.
The CLI now stores trusted-peer/keychain state in keychain_state.plist with
mode 0600 on Unix. On later runs it loads the state, verifies that the saved
identity is still in the clique, and skips escrow recovery when valid.
New options:
--keychain-state <path>--clear-keychain-state
The previous run did not save its private peer identity, so its newly visible
bottle cannot fully reconstruct that local state. One additional successful
join may be required with the fixed build; later runs should print
Reusing saved keychain trust identity and should not add bottles.
The live post-join failures were reproduced and diagnosed with:
RUST_BACKTRACE=1 \
RUST_LOG=icloud_auth=debug,omnisette=debug,rustpush::icloud::keychain=debug \
./target/debug/export-findmy --anisette-url "https://ani.neoarz.com"Post-fix verification:
cargo checkcargo test --bin export-findmywith all four tests passingcargo build- debug binary help output includes both keychain-state options
The next live run supplied a backtrace for a separate panic during Find My PCS zone-key decoding:
openssl::symm::Crypter::new: an IV is required for this cipher
rustpush::util::rfc6637_unwrap_key
rustpush::icloud::pcs::PCSKey::new
rustpush::icloud::pcs::PCSShareProtection::decode
rfc6637_unwrap_key passed the ID_AES128_WRAP cipher to OpenSSL's generic
openssl::symm::decrypt helper with None for the IV. With OpenSSL crate
0.10.80, the generic Crypter path reports an IV length for that cipher and
panics before initialization.
The RFC 6637 implementation now uses OpenSSL's dedicated RFC 3394 APIs:
openssl::aes::wrap_keyopenssl::aes::unwrap_keyAesKey::new_encrypt/AesKey::new_decrypt
These APIs correctly apply the RFC 3394 default IV. Both wrapping and unwrapping were switched so locally generated PCS data uses the same code path.
The surrounding decoder now validates:
- plaintext key size before wrapping
- compact ephemeral public-key size
- wrapped ciphertext length and block alignment
- RFC 6637 plaintext framing byte
- padding length and bytes
- checksum
Invalid or tampered inputs return RFC6637Error rather than panicking.
Two focused tests were added:
- RFC 6637 wrap/unwrap round trip
- tampered wrapped-key rejection
The adjacent INFO log that printed the unwrapped PCS master key was removed.
Focused tests, the application tests, and cargo check all pass after this
change.
The first successful export produced 13 decrypted MasterBeacon records, but
every output used the Unknown-* fallback and an empty emoji.
The CLI indexed BeaconNamingRecord by its decrypted associatedBeacon field
and KeyAlignmentRecord by beaconIdentifier, but then incorrectly looked
both up using MasterBeaconRecord.stableIdentifier. The live stable identifiers
were values such as l:/..., a:/..., and 2006~#..., so they could not match
the naming/alignment references.
Those reference fields point to the MasterBeacon CloudKit record identifier,
which is the id key used by upstream rustpush's FindMyClient::sync_items.
The assembly code now matches naming and alignment records using that CloudKit
record ID. stableIdentifier remains the exported accessory identifier and
filename disambiguator.
The CLI now reports how many accessories received matching naming and alignment records, plus counts for any orphaned related records. This makes association failures visible without logging decrypted record contents.
These counts are exact CloudKit-reference lookups, not fuzzy or heuristic matching:
- each
MasterBeaconRecordis indexed by its CloudKit record ID BeaconNamingRecord.associatedBeaconreferences that IDKeyAlignmentRecord.beaconIdentifierindependently references that ID
Naming and alignment are separate side records, so Apple can return different
counts for each. The successful live run had 13 MasterBeacon records, 12
matching naming records, and 11 matching alignment records. The nameless
MasterBeacon can be identified from the existing output as stable identifier
me:/deadbeef-10-dead-beef-deadbeefdead, model iPhone11,6.
It may be a stale or system-owned device record rather than a currently visible
named Find My accessory.
Upstream rustpush already treats alignment as optional and falls back when it is
absent. The current exporter does not serialize alignment fields into the
output plist, so missing alignment does not change the exported key material.
A missing naming record only causes the Unknown-* name and empty emoji
fallback.
The summary now also prints the stable identifier and model for every accessory missing a naming or alignment record. This will identify the two records without alignment on the next run without exposing decrypted keys or secrets.
The exported MasterBeacon private/public keys and secrets were unaffected by the naming bug; only the attached name, emoji, naming ID, and alignment lookup used the wrong key.
Verification after the association fix:
cargo checkcargo test --bin export-findmy
The dependency work was separated from the application so each repository has a reviewable history.
Fork:
https://github.com/stek29/apple-private-apis.git
Branch:
local/export-findmy-changes
Commits:
80461a4 Fix trusted-device and SMS 2FA transitions
d051b77 Add reusable Apple account cache serialization
b96e584 Use HTTPS for clearadi submodule
The branch is pushed and clean. The clearadi submodule now uses
https://github.com/OpenBubbles/clearadi-stub.git.
Fork:
https://github.com/stek29/rustpush.git
Branch:
local/export-findmy-changes
The work was rebased onto OpenBubbles rustpush master before publication.
Commits on top of that upstream revision:
7a67df2 Expose APIs needed for headless FindMy key export
2ea7154 Handle nested MobileMe delegate configuration
96c1228 Accept and diagnose escrow metadata variations
119d57e Avoid panics while decrypting keychain items
a0a9257 Use OpenSSL AES key-wrap APIs for RFC 6637
be556fd Use forked apple-private-apis dependency
eaa297f Update apple-private-apis submodule
The branch is pushed and clean. Its apple-private-apis submodule points to
commit b96e584 in the fork above.
After the root application was switched to the rebased RustPush fork, fresh debug builds failed during the MobileMe delegate request with:
AuthError({"status-message": "We can not process your request, please try again later.", "status": 1})
The older release binary still worked. Comparing the two dependency trees
showed that the OpenBubbles rebase had brought in the V2 delegate-login request,
even though the earlier investigation had concluded that it should not be used
by this headless configuration. The V2 request is intended to carry
X-Mme-Nas-Qualify validation data, while FakeIOSConfig returns no validation
data.
RustPush now selects the request format based on that capability:
- non-empty validation data uses the upstream V2 request
- missing or empty validation data uses the legacy delegate request used by the working release
The compatibility fix was committed and pushed as:
0f5bb1f Support delegate login without validation data
The root ignored lockfile was refreshed to 0f5bb1f, cargo check -p rustpush
completed successfully, all four exporter tests passed, and the debug binary
was rebuilt. A live Apple-account run is still required to confirm the remote
delegate response.
All five internal crates now resolve from the published rustpush branch:
rustpushomnisetteicloud_authkeystorecloudkit-proto
This was verified without relying on the local vendor/ tree. Cargo fetched
the fork, its apple-private-apis submodule, the HTTPS clearadi submodule,
and OpenAbsinthe, then passed all four root tests.
The application fork is:
https://github.com/stek29/export-findmy.git
The intended remote layout is:
origin https://github.com/stek29/export-findmy.git
upstream https://github.com/thisiscam/export-findmy.git
The root application commit should contain:
.gitignoreCargo.tomlREADME.mdsrc/main.rsworklog.md
Cargo.lock remains ignored. This project intentionally follows the published
RustPush development branch rather than pinning the complete resolved dependency
graph in the application repository.
The following local or sensitive material must not be committed:
keys/, which contains exported Find My private keysvendor/, which contains the dependency development checkoutstarget/.local/, which contains private device profiles, auth caches, keychain state, and other per-workspace sensitive state
The scripts under scripts/ are a separate FindMy.py conversion and location
fetching workflow. They should be committed separately only after documenting
their Python dependencies and ignoring their sensitive account.json state.
Added the --delete-own-escrow-bottle maintenance mode. It authenticates,
loads the escrow bottle list, displays each bottle's device description,
serial, build, and escrow timestamp, and exits without joining the keychain or
exporting accessories.
Deletion uses the selected viable bottle's escrow label with the existing
targeted KeychainClient::delete API. It does not call reset_clique, which
would attempt to remove every viable bottle.
Because Apple's viable bottle list can include records belonging to real devices, deletion requires four deliberate actions:
- Select the bottle index.
- Successfully unlock the selected bottle with its device passcode/password.
- Enter
DELETE <index>. - Re-enter the selected device serial immediately before deletion.
Pressing Enter at selection or failing either confirmation cancels without making a deletion request.
The deletion path now branches immediately after Apple authentication and
bypasses the MobileMe delegate request. Escrow listing and deletion use the GSA
token directly; requiring MobileMe made this maintenance operation fail when
Apple temporarily rejected the unrelated iosbuddy/loginDelegates request.
Saved keychain state supplies the escrow host when available, otherwise the
existing default escrow proxy host is used.
The README now makes escrow cleanup part of the recommended post-export flow. It tells users to delete the synthetic exporter bottle once it is no longer needed, identifies it using the selected device profile, and warns against deleting records belonging to real Apple devices.
A hidden --delete-own-escrow-bottle-without-password escape hatch enters the
same interactive deletion flow but skips recovery of the selected bottle. It
is intentionally omitted from --help and the README. The index-based
DELETE <index> confirmation and exact serial confirmation remain mandatory,
and the CLI warns when password verification has been bypassed. This exists
for cleanup if the configured bottle password is unavailable.
Replaced the process-local synthetic identity and fixed findmy-export
password with a persistent profile-centered workspace.
The committed device-profile.template.toml has the same [device],
[software], and [escrow] schema as the private profile. The README tells
users to copy it to .local/device-profile.toml, edit the copy, and pass it
with --device-profile. The CLI refuses to run directly from a
*.template.toml file. The .local/ directory is ignored by Git. Empty UUID
and UDID fields are generated on first use and written back to the copied
profile; all other identity fields are required.
The profile controls the device name, serial, hardware model, escrow model
class, UUID, UDID, OS version, build, CFNetwork version, and Darwin version.
The default escrow model class remains iMac. RustPush commit bdd6399 added
a backward-compatible OSConfig::get_model_class() method so escrow metadata
no longer hardcodes this value inside the keychain client.
Unless an existing explicit path option overrides them, local artifacts are now derived from the selected profile directory:
keys/auth_cache.plistkeychain_state.plistkeystore.plistanisette_state/
When a synthetic bottle must be created and no password is configured, the CLI
offers to generate a 16-character random password or accept a user-entered
password with confirmation. No minimum length is enforced; an empty password
requires an explicit warning confirmation. The password is stored in the
private profile alongside the persistent identity. Profile updates use an
atomic temporary-file replacement and mode 0600 on Unix. The password is
never printed or serialized into plist state.
EXPORT_FINDMY_ESCROW_PASSWORD remains an ephemeral override and is not
written into the profile. A separate password-file override is no longer
needed because the private profile is the single configuration artifact.
Bottle deletion uses the saved profile password when the user presses Enter,
but still accepts any manually entered password, including the former
findmy-export value for an older bottle. The hidden password-verification
bypass remains available when no password is known.
The original exporter wrote only Apple .plist files. The downstream
FindMy.py project also accepts a native
JSON format with pre-derived fields such as master_key, skn, sks,
alignment_date, and alignment_index. The Python script
.local/old/scripts/plist_to_json.py performed this conversion offline.
This session moved the conversion into the exporter itself so both formats are produced in a single run, eliminating the separate Python dependency for users who only need the JSON.
Added:
serde_json = "1.0"for JSON serializationhex = "0.4"for hex encoding of binary key materialchrono = "0.4"for ISO 8601 datetime formatting
Serial number parsing (serial_from_identifier):
Ported the Python ~#¶§ parsing logic from plist_to_json.py to Rust.
Supports three observed identifier layouts:
- AirTag:
2006~#<hwid>~#<serial>— serial is the tail after the last~# - AirPods:
a:/<uuid>~#¶<model>§<hwid>§<hex-ascii-serial>§<position>— serial issections[2]hex-decoded to ASCII - 3rd-party:
a:/<uuid>~#<serial>— serial is the tail after~#
Returns None when the identifier contains no ~# delimiter.
JSON accessor generation (accessory_to_json):
Maps BeaconAccessory fields to the FindMy.py FindMyAccessoryMapping schema:
| JSON key | Source |
|---|---|
type |
"accessory" |
master_key |
Last 28 bytes of private_key, hex-encoded |
skn |
shared_secret, hex-encoded |
sks |
shared_secret_2 or secure_locations_shared_secret, hex-encoded |
paired_at |
pairing_date as ISO 8601 / RFC 3339 |
name |
naming.name |
model |
master_record.model |
identifier |
master_record.stable_identifier |
group_identifier |
master_record.group_identifier (from CloudKit groupIdentifier field) |
serial_number |
Parsed from stable_identifier via serial_from_identifier |
alignment_date |
alignment.last_index_observation_date as ISO 8601 |
alignment_index |
alignment.last_index_observed |
Dual-format Step 7:
The export loop now:
- Computes a single
accessory_basename(e.g.Keys_AirTag_ID-123) - Writes
{basename}.plistviaplist::to_file_xml - Writes
{basename}.jsonviaserde_json::to_string_pretty
The existing collision check (output_paths HashSet) guards both extensions.
Success message:
Updated from "Exported N accessory plist file(s)" to "Exported N accessory file pair(s) (plist + json)".
Tests added:
serial_from_airtag_identifier— tail extractionserial_from_airpods_identifier— hex-decode path (¶§485830304141§→HX00AA)serial_from_third_party_identifier— direct tailserial_from_identifier_without_tilde_hash— returnsNoneaccessory_basename_produces_expected_format— basename consistencyjson_output_has_expected_schema— round-trip field verification against a syntheticBeaconAccessory
- Updated the project description to mention both
.plistand.json - Updated the
--output-diroption description - Updated the example run to show both files being written
- Added a detailed "Output format" section with separate plist and JSON tables
- Updated the security warning to reference both file extensions
- Updated the "How it works" step 6 to mention "matched plist and json file pairs"
Completed successfully across the development session:
cargo checkcargo test --bin export-findmy— all 14 tests pass, including the 6 new ones for serial parsing and JSON outputcargo build/cargo build --release
The Apple authentication and escrow paths require account-owner runtime
testing. The live logs supplied during the session confirmed the 2FA fix
progressed through login, isolated the escrow failure to the missing optional
passcodeGeneration field, and produced a successful trust-circle join.
The group_identifier field (maps multi-part accessories like AirPods to
their parent group) was hardcoded to null. FindMy.py requires it to
resolve group names.
The MasterBeaconRecord struct in vendored rustpush lacked the field; the
CloudKitRecord derive macro silently discards unknown CloudKit fields.
Changes:
vendor/rustpush/src/findmy.rs— addedgroup_identifier: Option<String>src/main.rs— wired intoaccessory_to_json(),accessory_to_plist(), and test fixtureREADME.md— addedgroupIdentifierto plist table, removed "always null"worklog.md— updated field mapping table
Pushed to stek29/rustpush branch local/export-findmy-changes, then
cargo update -p rustpush pulled the new commit. Builds and all 14 tests
pass.
Note: Confirmed via FindMySyncPlus
(Swift, reads fmipcore cache) that groupIdentifier is a string UUID on
Items.data entries matching a Devices.data parent's baUUID. Whether
CloudKit's MasterBeaconRecord carries the field directly or it lives in a
separate record type remains to be verified — the derive macro logs unmapped
fields at info! level.
Replaced the automatic 2FA flow (which followed Apple's au field
deterministically) with a staged state-machine API, matching how
FindMy.py handles 2FA: always fetch
the phone list and present all methods regardless of au, then let the user
choose.
New types in icloud-auth: SecondFactorMethod, LoginStep,
PendingSecondFactor. New methods on AppleAccount: login_step_start,
login_choose_method, login_submit_code. Existing login() and
login_with_anisette() remain unchanged.
CLI (login_interactive) now shows a numbered menu of available methods
(trusted device + SMS per phone number) and prompts for the code.
Verified: cargo check, cargo test --lib (10 icloud-auth tests), all 14
export-findmy tests. Trusted device flow live-tested. SMS flow implemented but
not yet live-tested.
Changed plist generation to match the Apple plist structure consumed by
FindMyAccessory.from_plist:
- Truncate plist dates to whole UTC seconds because Python's
plistlibrejects fractional plist dates. - Store cryptographic blobs under the Apple keychain-style
key.datadictionaries instead of as raw plist data. - Store
stableIdentifieras a one-element array so FindMy.py can extract the serial number. - Keep the scalar
identifierfield used as the accessory identifier.
FindMy.py successfully fetched and decrypted location reports using both the native Rust JSON and JSON produced from the corrected plist. The native JSON required no changes.
Known FindMy.py conversion behavior:
FindMyAccessory.from_plistignores the plistname, so converted JSON hasname: nulland the example displays the stable identifier.- Without a separate
--alignment-plist, converted JSON starts with the pairing date and alignment index0; report fetching subsequently realigns it. - Plist dates have whole-second precision. Native JSON preserves the original RFC 3339 fractional seconds; Python accepts the nanosecond timestamp and truncates it to microseconds.