VFLIN

docs · docs/30-core/PROFILE_STORAGE_SYNC.md · synced 2026-11-08

Profiles and sync

signed, encrypted blobs; the lifecycle; version vectors; what a running profile never does

Purpose: define the profile lifecycle, on-disk/at-rest forms, and the sync protocol. This is the model proven in the field by shipping antidetect products (<uuid>.zip + <uuid>.sig, partial local materialization) and formalized here (ADR-0004).

1. Two forms of a profile

  • Runtime form: a live user-data-dir Chromium can use (cookies, localStorage, IndexedDB…).
  • Storage/sync form: a Profile Blob = manifest + payload:
    • payload = user-data-dir → deterministic archive (tar/zip) → compressed → encrypted (AEAD).
    • <uuid>.blob (ciphertext) + <uuid>.sig (signature over ciphertext+manifest).
    • manifest (cleartext, integrity-protected): uuid, version vector, size, core version last used, fingerprint id, createdAt, deviceId, contentHash.

Bridges between the two: materialize (blob → dir) and pack (dir → blob). They are the only sanctioned conversions.

What a blob does NOT hold — and why the profile is still whole (A98)

A profile’s live directory is mostly not the profile. Measured on a real park (the owner’s own, 121 profiles, 2026-09-14), the heaviest profile’s 2 948 MB of live directory broke down as:

bucket MB share in the blob?
identity — cookies, Local Storage, IndexedDB, extensions, the wallet keyring, Preferences, history, bookmarks 0.7 0.0% yes, always
Chrome’s own downloads — OptGuideOnDeviceModel (2 730 MB alone), component_crx_cache, TranslateKit, WasmTtsEngine, OnDeviceHeadSuggestModel, the component-updater payloads, Crashpad, BrowserMetrics* 2 944.4 99.9% no
cache — Cache, Code Cache, GPUCache, the Dawn caches 1.9 0.1% yes, on purpose

pack drops the middle bucket (EXCLUDE_TOP_LEVEL in vflin-crypto’s archive.rs). Three properties make a name eligible and all three are required: Chrome writes it through the component updater or the crash/metrics subsystem, it is identical for everybody, and it comes back by itself. The exclusion matches the FIRST path segment only, so nothing inside Default/ — which is the profile — can ever be reached by it.

The profile’s passkeys are identity, and in the blob (A133, ISSUE-311 — the owner’s F1b: «сохраняться и даже передаваться экспортом»). With identity.passkeys: "profile" (patch 0059) the profile’s own WebAuthn authenticator keeps its keys in <user-data-dir>/Vflin Passkeys — a top-level FILE no exclusion names, so pack seals it like any other identity file, and the bundle carries it to profile.file.export, cloud sync and another device of the same account. Measured (tests vflin-profiles …passkey_store_is_sealed…, daemon …passkey_store_travels_in_the_profile_export, and gated passkeys_live_gated on the s48 core against the owner’s test site): no plaintext after the pack, byte for byte after materialize and after the bundle moved to another store, and the same passkey signs in after a restart and from the other store. Nothing had to change in the archive; the switch (profile.update {passkeys}) is what was missing.

The cache is kept, and A98 re-measured that decision rather than re-arguing it: a browser whose cache is empty at the start of every session is rare, and rarity is what gets a profile caught. On the heaviest real profile the whole cache bucket was 1.9 MB, so there was never a gigabyte here to trade.

Measured, same input, before and after:

profile live dir blob before blob after
the owner’s heaviest 2 948 MB 2 380 MB (the artifact on his disk) 0.06 MB
a profile after twelve real pages 66.2 MB 28.89 MB 17.63 MB

Old blobs need no migration. pack runs on every profile.stop, so a profile shrinks the first time it is opened and closed — there is no rebuild step, and a park that is never opened keeps its old bytes until it is. A profile at rest is only its blob: pack secure-wipes the live directory, so the second copy exists only while the profile is running.

Six weeks later the cache IS the heavy profile (A209, ISSUE-571, measured)

The table above was true on 2026-09-14 and is not any more. Measured on the owner’s own export of his heaviest profile (2026-10-24, p2): the same archive the seal carries and the same zstd level (plain_export.rs says the two are one), read as a stream, sizes summed per directory, nothing unpacked to disk and no site named. Packed = each bucket through its own zstd level-3 stream; the buckets add up to 659.5 MiB against the file’s 658.

bucket raw MiB packed MiB comes back by itself?
Default/Cache/Cache_Data — the HTTP cache Chrome uses 459.7 256.6 yes
Default/Cache/old_Cache_Data_000 — a cache Chrome reset and had not finished deleting 231.4 213.6 it is garbage: Chrome deletes it
Default/Code Cache — compiled JS / wasm 303.3 85.1 yes
Default/Service Worker/CacheStorage — sites’ offline caches, 9 sites, the largest 171 MiB raw 261.2 63.1 yes, on the next visit
Default/Service Worker/ScriptCache — the workers’ scripts 51.4 15.4 yes, but a registration without its script is dropped by Chrome — kept with the registrations
Default/IndexedDB — sites’ databases, 13 sites, the largest 23.8 MiB raw 36.0 22.0 no
Default/Sessions — open tabs to restore 29.7 1.3 no
GPU / Dawn caches 11.7 0.1 yes, machine-keyed
everything else: cookies, passwords, Local / Session Storage, extensions’ state (the wallet keyring), history, favicons, preferences, Local State, vflin-fp-config.json 20.2 2.3 no
total 1 404.6 659.5

What sync is obliged to carry — what does not come back by itself, plus the workers’ scripts under the “unsure means kept” rule: ≈ 41 MiB packed of 659 (6 %) on the heaviest profile. Everything else in it regrows. A typical profile is a different scale altogether: across 663 seals in the owner’s journal the median is 0.00 MB, p90 0.10, p99 0.20, the largest 238.3 MB packed (449.8 raw) — another profile of his, on 2026-10-17; the exported one never sealed in that journal (it lives in another store). Two heavy profiles, then; only the exported one is measured by directory — the other is a sealed blob, and opening it is opening his data.

Chrome’s garbage no longer rides (A209, PENDING_DELETION in archive.rs): a directory Chrome renamed to old_<cache>_<NNN> for deletion (net/disk_cache/cache_util.cc) at the cache’s own place is left out of the pack — on this profile that alone is 214 of 659 MiB packed.

What travels and what stays on this computer (A210, ISSUE-571)

The rule, from coordination’s brief: if it comes back by itself, it does not travel. One walk of the folder makes two archives (vflin_crypto::pack_dir_parts_timed, the list is LOCAL_ONLY in archive.rs):

group where why
HTTP cache (Default/Cache) stays on this computer a copy of what sites serve; refilled on the next visit
compiled-code cache (Default/Code Cache) stays V8 rebuilds it from the scripts
GPU / Dawn caches (Default/GPUCache, Dawn*) stays keyed to this machine’s GPU and driver
sites’ service-worker caches (Default/Service Worker/CacheStorage) stays measured on the owner’s profile by each entry’s URL and response type: scripts, styles, HTML shells, images — 0.02 MiB of anything else; a worker refills its cache
old_<cache>_NNN nowhere (A209) Chrome’s garbage
service-worker REGISTRATIONS and SCRIPTS (Database, ScriptCache) travel push subscriptions live there, and Chrome drops a registration whose script is missing
cookies, Local / Session Storage, IndexedDB, File System, extensions’ state, history, preferences, Local State, vflin-fp-config.json travel this is the person

What stays is still sealed and still comes back on THIS computer. pack seals the caches beside the blob as local-cache + local-cache.sig — a DEK of their own wrapped under the account’s Master Key, AEAD, signed by this device, the blob’s own primitives — and materialize brings them back before the folder goes live, so a start here is as warm as before (A98’s reason for keeping the cache stands). The cloud bundle, the export file and another machine see only the blob: a profile arrives elsewhere with a cold cache, as a browser on a new device does. A local cache that is missing, damaged, another profile’s or signed by a key this account does not hold is simply not restored — a cold start, never a failed one — and its plaintext staging folder is wiped either way. A blob sealed before A210 still carries its caches inside; those win over the local ones.

Measured through the product (A210 report): a profile signed in to a site on loopback that recognises the person (a cookie the server reads, localStorage and IndexedDB the page reads) and fills every cache group — the seal: 0.1 MB travels, 9.3 MB stays (it was one 9.4 MB blob); the export file 0.06 MB. Started again on the same home: the site recognises the person and none of the six cached scripts is fetched again; the worker’s cache is back. Imported on a CLEAN home of another account: the server reads the same cookie, the page reads the same localStorage and IndexedDB, and all six scripts are fetched again (a cold cache). On the owner’s heaviest profile the same rule leaves ≈ 41 MiB packed travelling of the 659 its export carried (137 MiB raw of 1 405).

2. Lifecycle state machine

            create / import
                  │
            ┌─────▼──────┐  pull(blob)     ┌──────────────┐ materialize ┌───────────────┐
            │ CLOUD_ONLY │────────────────►│ LOCAL_CACHED │────────────►│ MATERIALIZED  │
            └─────▲──────┘                 └──────▲───────┘             └──────┬────────┘
                  │ evict                          │ pack                       │ start
                  │                                │                            ▼
          ┌───────┴────────┐  upload        ┌──────┴───────┐   stop      ┌──────────────┐
          │ SYNCING_UPLOAD │◄───────────────│    DIRTY     │◄────────────│   RUNNING    │
          └────────────────┘                └──────────────┘             └──────────────┘
   remote newer │                                  ▲
                ▼                                   │ resolve
        ┌────────────────┐                   ┌──────┴──────┐      lock held elsewhere
        │ SYNCING_DOWNLOAD│                  │  CONFLICT   │◄──────  LOCKED_REMOTE
        └────────────────┘                   └─────────────┘

State definitions:

  • CLOUD_ONLY — only the blob exists in cloud; nothing local.
  • LOCAL_CACHED — blob present locally, not unpacked.
  • MATERIALIZED — live dir exists, not running.
  • RUNNING — a core is using the dir (exclusive; no sync may touch it).
  • DIRTY — stopped with local changes not yet uploaded.
  • SYNCING_UPLOAD/DOWNLOAD — transfer in progress.
  • CONFLICT — local and remote diverged (both changed since common base).
  • LOCKED_REMOTE — another device holds the run/edit lock.

3. Encryption & signing (zero-knowledge, multi-KEK — ADR-0008)

  • Envelope hierarchy: each profile payload is AEAD-encrypted with a random per-profile Data Key (DEK). The DEK is wrapped independently by multiple KEKs — any one unwraps it. Server stores only ciphertext + the wrapped-DEK copies → server cannot decrypt on any path.
  • KEK factors: password (Argon2id) + a mandatory Recovery Key (offline emergency kit); optional passkey (WebAuthn PRF) and OS keychain; team profiles add an org-key KEK (public-key wrap) for admin-assisted recovery — still zero-knowledge to the server.
  • AEAD (AES-256-GCM or XChaCha20-Poly1305) for payload; signature (Ed25519) over manifest+ciphertext for tamper/anti-corruption detection (the .sig).
  • Recovery: lose one factor → recover via another. Lose all factors → lose the profiles (honest E2EE floor). No server-side escrow. Details: KEY_RECOVERY_COMPARISON.md, SECURITY_POLICY §4.
  • A preview from the head is not verified — so the record says whose it is (A220, ISSUE-710). A new computer shows the account’s profiles from each copy’s first bytes (A219, ISSUE-706): the manifest and its sealed record, before the signature, which covers the whole blob, can be checked. The manifest’s profileId is cleartext, so the sealed record carries profile_id too, under the profile’s DEK; a preview takes everything only from a record sealed for the profile it was fetched for, refuses a head that names another profile (and does not read it again while the account’s list names the same copy), and from a record sealed before A220 takes its labels only – name, folder, tags, notes – until the verified copy arrives. The binding is written by every pack and kept by every record refresh; older programs ignore the field, and the blob signature (over the record’s ciphertext) is unchanged by it. Not in the manifest: a new manifest field changes the canonical bytes an older program re-serializes, and its signature check would fail.

3a. The exits and their bindings travel with the account (A221)

A profile’s record carries its exit’s ADDRESS; the login and the password live in the register’s vault entry (proxy/<id>/…), and a start reads them through profile.proxy_id. So the register itself travels: its rows by their own id (a random UUID — one exit everywhere) and the map «profile → exit», inside the account’s settings document, sealed under the Master Key (exits_sync, beside settings_sync’s folder looks). Merged entry by entry by the time each changed, a deletion as a tombstone, absence never a deletion; the same login at the same address under two ids becomes the smaller id everywhere. What an exit measured stays on the computer that measured it. Never matched by address — one gateway serves many logins. The section is compressed; the server’s 64 KiB settings request carries about 120 gateway-shaped exits (512 KiB, about 960, once S58 is in production and the client follows; measured), past which the sync refuses in words (SETTINGS_TOO_LARGE) and keeps everything here. The envelope’s ciphertext as an array of numbers is most of that weight (ISSUE-720).

3b. A profile’s status travels, and so does the account’s list of statuses (A223)

The sealed record carries the profile’s status id (PortableRecord.status; None in records sealed before A223, which older programs ignore), taken by every road that makes a profile from a record — a preview, the copy fetched, a file import. The id names an entry of the account’s list (settings.profileStatuses), and that list travels in the account’s settings document beside the folder looks (Travelling.profileStatuses), last writer wins the document as the looks do. A document without the list — an older program’s, or a machine still on the built-in four — takes nothing away.

Since A224, each profile’s status travels in the account’s document too — and that is the senior source. The record travels only with its copy, so a status set before A223 on a profile not opened since never left its computer (the owner’s laptop on a224 showed none). The document now carries a map, profile id → {at, status} (settings_sync::Travelling.profileStatusMap, status_map), merged entry by entry as the exits’ bindings are: the later at wins, a tie keeps the account’s. A status known only from a record enters at at = 0, below anything a person sets; a person’s change is noted with its time (status_map::note_change), and «no status» is an explicit null, so a status taken off one computer does not come back from another’s old record. Every arrival (a preview, a copy, a file) takes the map’s word when the map names the profile, and the record’s only when it does not. A list of statuses the account’s copy lacks goes up with no edit (A224: a list made before A223 never marked the document changed).

3c. A wallet’s record does not travel without the wallet (A223, ISSUE-727)

A wallet lives in two places: its binding and unlock password in the sealed record, its keys in the payload (Default/Local Extension Settings/<module id>/). The owner’s P2 reached his second computer with the first and not the second, and the start made an empty wallet under the profile’s password. Four rules keep the two together (since A224 they count the wallets «Отвязать» KEPT in the keyring — detached_wallets — as much as the attached ones: their keys are in the same payload):

  1. The module opens a wallet, it never makes one (module patch 0009): with an empty storage the early unlock says not-booted and writes nothing. Seeding goes through the controller’s own boot, never through the unlock.
  2. A start does not open a wallet whose data is not in the copy — read from the unpacked folder, said in the journal.
  3. A push refuses WALLET_NOT_IN_COPY (409) when the record names a set-up wallet and the copy’s seal found that the wallet is not in it. The daemon’s one seal reads the module’s own storage off the live folder before it wipes it (vflin_wallet_rabby::wallet_in_storage, read only: Chrome’s LevelDB log for that extension, two of Rabby’s keys – keyringState and preference.currentAccount) and notes the answer beside the copy (payload.wallet, local, dropped when a copy is taken from the cloud). No – no storage, or a keyring with no keys (a password and no address: what an older module made on the owner’s second computer) – refuses the push; the profile’s wallet – keys, and the record’s address current – lets it go; cannot tell – a damaged or foreign record, a table beside the log or more than one log (which writes are newest is not read here; an old log left beside a table can hold a keyring a newer one replaced – Codex keys-2026-10-31T035404Z), keys under another address – drops the note and is said in the journal, never taken for «no». The push decrypts nothing, and a locked daemon checks it too. A refusal in the sync ledger, asked again after the next seal.
  4. A copy taken from the cloud keeps the one it replaces (blob.prev, as a seal does).

Measured (core 157, nine real storages from vflin-wallet-rabby/tests/rabby_popup_states.rs): empty, a password and no address (open and locked), an empty one started the product’s way (twice) – all «no»; a seeded wallet over four starts – «the profile’s wallet». In all nine every write is in the log, no table at all: the log grows about 9.5 KB a start, and LevelDB turns it into tables past its 4 MB buffer – some 400 starts of one wallet. A storage with a table is «cannot tell» here: reading it needs the manifest (which log is live) and the tables, compressed with snappy – a new crate, waiting for the owner’s word (ISSUE-727).

What rule 2 does not catch. The start checks only for the storage’s FILES: a copy that ran once without its wallet has them, and only rule 1 keeps the module from making an empty wallet there. Rule 3 reads the storage, so such a copy – and the owner’s P2 as it is now – does not travel.

4. Sync protocol

  1. Lock: sync.lock.acquire(id) before upload/edit. Locks are leased (TTL) + owner-stamped.
  2. Version vectors: each blob carries {deviceId: counter}; compare to detect fast-forward vs divergence.
  3. Upload: pack → encrypt → sign → multipart upload to object storage → commit manifest in metadata DB.
  4. Download: fetch blob → verify sig → decrypt → materialize on demand.
  5. Conflict: if remote advanced beyond local base while local is DIRTY → CONFLICT. Default resolution: last-writer-wins guarded by lock; CONFLICT keeps both blobs (a/b) for manual pick. What is built (A224, the owner’s #81) — the manual pick, cloud.resolve. Nothing resolved a conflict before it: the cloud refuses a copy that does not dominate its own, a pull keeps what is here, and the contract’s «resolve with cloud.pull / cloud.push» could not. One choice, three answers: cloud — the cloud’s copy and its record become this computer’s (the fields cloud.onboard takes; this computer’s start pages, launch arguments and extensions stay); here — this computer’s copy is resealed past the cloud’s (each counter the larger, its own one past it — ADR-0016 §3) and sent over it; keptCopy — the copy the last choice kept aside is the main one again, everywhere. The copy not chosen is kept aside on THIS computer — blob.kept + blob.sig.kept + kept.json (who sealed it, its vector, when), the note written last so a keeping cut short leaves none. No seal replaces it — blob.prev would be gone at the next close, and the window’s «не потеряется» would be false — it stays until the next choice for the profile or its deletion (discard takes it). Only at rest, under the launch lock, with the account unlocked; here and cloud only while the cloud says the two changed apart. A failed step puts back the copy here and the kept one as they were. What it does not do: the other computer keeps its own copy until its next start takes the main one; the kept copy is on the computer where the choice was made, nowhere else; a cloud copy taken by an ordinary pull (newer) still brings its bytes and NOT its record (name, notes…), as before. And the rows nothing named (A224, the owner’s laptop: by sync {"synced": 2, "unknown": 88}): copies fetched in the background by a build before A220 were never written into the sync ledger, and only a start or a seal writes a row. The tick now asks up to five such profiles a minute (at rest, sealed, in the account’s cloud list, no standing) the pull’s read-only question and writes synced / conflict; newer has no standing in the ledger, so such a row stays as it is until its start takes the copy.
  6. Eviction: LOCAL_CACHED/MATERIALIZED can be evicted to CLOUD_ONLY under disk pressure (LRU), never if DIRTY/RUNNING.

5. Local-first guarantee

With no cloud account, everything works: profiles live as local blobs + dirs, lifecycle minus the SYNCING/LOCKED_REMOTE states. Cloud is an additive layer, never a dependency (ARCHITECTURE.md §1.3).

5b. The export file — ONE file, no encryption, any account opens it (A174, ADR-0050)

Read ADR-0050 before changing anything here; this is a recorded decision of the owner’s, with the objection to it recorded beside it. He said what the export button is for — «это должен быть файл, чтобы кому-то другому дать, и он с любого акка импортнёт» — coordination objected that a profile carries cookies, live sessions, saved passwords and 2FA secrets, and he confirmed and narrowed it: encryption stays for the store on disk and for everything that travels through our server between machines of one account, and the export file has none.

the export file the sealed blob
name <profile>.vflinx <profile>.vflin (written before A174)
first bytes VFLINX1 VFB1
header plaintext JSON: FIRST a notice in plain English and Russian — «NOT ENCRYPTED… / НЕ ЗАШИФРОВАН…», what whoever has the file has, written by the writer from what rides (A209) — then format, formatVersion, the record, the two secrets, the archive’s hash cleartext manifest, signed; the record sealed under the DEK
payload the user-data archive, zstd, plaintext the same archive, zstd, AEAD under a per-profile DEK
who opens it anyone an account whose keyring unwraps that profile’s DEK
what proves it a hash: the bytes arrived whole. Not who wrote them — the file is unsigned an Ed25519 signature over the manifest and the ciphertext

Three ways it names itself, because confusing the two is the one mistake that costs data, and either a person or our own import could make it: the extension, the magic, and the header’s own format / formatVersion. plain_export::sniff is the single place that decides which a file is, from its head alone.

Two bridges, not three (§6): BlobStore::write_plain_export is blob→file and BlobStore::adopt_plain_archive is file→dir, both beside materialize in the store rather than copied into the daemon. The second refuses a directory that already has content instead of writing over it.

The import mints a fresh id, and that is load-bearing twice over: a file meant for someone else must import on any account and any number of times, which a travelling id would refuse the second time; and the sender’s replica counters must never enter the receiver’s version vector, or the profile’s first cloud push reads as diverged with no path back to convergence (ADR-0016). It therefore always CREATES — a plaintext file has no author, so it cannot prove it is this profile’s own backup, and letting an id in such a header pick a destructive path is the defect class this repo keeps paying for. The byte-identical Trash restore (A111) stays on the sealed path, which can prove it.

The profile is back under protection before the call returns (ADR-0050 §3): the payload becomes the new profile’s live directory and is sealed at once by the ordinary pack, under the RECEIVING account’s key, with the wallet password and the OS-crypt key the file carried stored where this device keeps its own. All or nothing — a failure at any step destroys the record, the tree and anything sealed.

Nothing may call this file protected (ADR-0050 §5, invariant 6): no lock on an icon, no «зашифрован», no «безопасно». profile.file.export’s answer carries encrypted: false to be read, and a test over this crate’s source fails if the export path’s own sentences start claiming otherwise.

6. Invariants (enforced by Profile Lifecycle, never bypassed)

  • A RUNNING profile is never packed/uploaded/evicted.
  • Sync never edits a live user-data-dir (DO_NOT_DO.md).
  • The local cache never travels (A210): local-cache is written and read only by pack and materialize on this computer — not by read_cloud_bundle, export_bundle_to, write_plain_export or anything that sends bytes.
  • Materialize/pack are the only dir↔blob conversions — plus the two the export file needs (A174, §5b): write_plain_export (blob→file) and adopt_plain_archive (file→dir), which live in the store beside materialize precisely so they are counted and not scattered.
  • “No plaintext profile leaves the device” is overridden for ONE operation, deliberately (A174, ADR-0050): profile.file.export, by the owner’s decision, on his data, for handing a profile to someone else. It holds everywhere else without exception — the store on disk, every byte through our server, team shares. Written here rather than quietly dropped, because an invariant that is false in one place and still listed as absolute is how the next change makes it false in a second.
  • Every state transition emits a profile.state_changed event + (for dangerous ones) an audit entry.
  • Materialized = the only plaintext window. While MATERIALIZED/RUNNING the user-data-dir is plaintext on disk (Chromium needs it). On pack/evict the dir is securely wiped; the window is kept minimal. (Local-compromise residual risk: THREAT_MODEL §4.) Since A176 (ISSUE-474) the wipe is no longer on the save’s path: once the new pair is durable and verified, the live dir is renamed to user-data.wipe-<stamp> beside it and a thread overwrites every file with zeros, flushes each and removes it — the same wipe, moved off the time the person waits (it was 3.3–3.6 s of a 13.5 s seal on a heavy profile). The user-data name is free at once, so a start right after materializes into a fresh folder. The daemon’s exit waits for its wipes (up to 60 s); a folder a process left half-wiped is wiped by recover_interrupted at the next start. Compression is zstd level 3 on several threads (it was 9 on one: the largest step, 5.3–6.6 s of the same seal; level 3 costs +2.3 % size, and blobs sealed at 9 open unchanged). Every seal writes its step-by-step cost to daemon.log (seal <id>: … ms: before, walk, read, hash, compress, encrypt, sign, write, verify, rotate, wipe). Since A210 the head of that line (N files, X MB -> Y MB) is what TRAVELS, and the caches kept here are appended: ; stays on this computer: N files, X MB -> Y MB, T ms.
  • materialize never hands a raw path to remote clients. The path goes only to the local UI/owner context; CLI/MCP/script clients get an opaque handle, and any raw-path return is power mode + audited — a path would otherwise bypass the single control boundary.

Open questions

Archive determinism across OSes, partial/delta sync vs full-blob, lock TTL + steal policy, conflict UX, key recovery options (social/escrow?) → OPEN_QUESTIONS.md.