logins/
schema.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5//! Logins Schema v4
6//! ================
7//!
8//! The schema we use is a evolution of the firefox-ios logins database format.
9//! There are three tables:
10//!
11//! - `loginsL`: The local table.
12//! - `loginsM`: The mirror table.
13//! - `loginsSyncMeta`: The table used to to store various sync metadata.
14//!
15//! ## `loginsL`
16//!
17//! This stores local login information, also known as the "overlay".
18//!
19//! `loginsL` is essentially unchanged from firefox-ios, however note the
20//! semantic change v4 makes to timestamp fields (which is explained in more
21//! detail in the [COMMON_COLS] documentation).
22//!
23//! It is important to note that `loginsL` is not guaranteed to be present for
24//! all records. Synced records may only exist in `loginsM` (although this is
25//! not guaranteed). In either case, queries should read from both `loginsL` and
26//! `loginsM`.
27//!
28//! ### `loginsL` Columns
29//!
30//! Contains all fields in [COMMON_COLS], as well as the following additional
31//! columns:
32//!
33//! - `local_modified`: A millisecond local timestamp indicating when the record
34//!   was changed locally, or NULL if the record has never been changed locally.
35//!
36//! - `is_deleted`: A boolean indicating whether or not this record is a
37//!   tombstone.
38//!
39//! - `sync_status`: A `SyncStatus` enum value, one of
40//!
41//!     - `0` (`SyncStatus::Synced`): Indicating that the record has been synced
42//!
43//!     - `1` (`SyncStatus::Changed`): Indicating that the record should be
44//!       has changed locally and is known to exist on the server.
45//!
46//!     - `2` (`SyncStatus::New`): Indicating that the record has never been
47//!       synced, or we have been reset since the last time it synced.
48//!
49//! ## `loginsM`
50//!
51//! This stores server-side login information, also known as the "mirror".
52//!
53//! Like `loginsL`, `loginM` has not changed from firefox-ios, beyond the
54//! change to store timestamps as milliseconds explained in [COMMON_COLS].
55//!
56//! Also like `loginsL`, `loginsM` is not guaranteed to have rows for all
57//! records. It should not have rows for records which were not synced!
58//!
59//! It is important to note that `loginsL` is not guaranteed to be present for
60//! all records. Synced records may only exist in `loginsM`! Queries should
61//! test against both!
62//!
63//! ### `loginsM` Columns
64//!
65//! Contains all fields in [COMMON_COLS], as well as the following additional
66//! columns:
67//!
68//! - `server_modified`: the most recent server-modification timestamp
69//!   ([sync15::ServerTimestamp]) we've seen for this record. Stored as
70//!   a millisecond value.
71//!
72//! - `is_overridden`: A boolean indicating whether or not the mirror contents
73//!   are invalid, and that we should defer to the data stored in `loginsL`.
74//!
75//! ## `loginsSyncMeta`
76//!
77//! This is a simple key-value table based on the `moz_meta` table in places.
78//! This table was added (by this rust crate) in version 4, and so is not
79//! present in firefox-ios.
80//!
81//! Currently it is used to store two items:
82//!
83//! 1. The last sync timestamp is stored under [LAST_SYNC_META_KEY], a
84//!    `sync15::ServerTimestamp` stored in integer milliseconds.
85//!
86//! 2. The persisted sync state machine information is stored under
87//!    [GLOBAL_STATE_META_KEY]. This is a `sync15::GlobalState` stored as
88//!    JSON.
89//!
90
91use crate::error::*;
92use lazy_static::lazy_static;
93use rusqlite::Connection;
94use sql_support::ConnExt;
95
96/// Version 1: SQLCipher -> plaintext migration.
97/// Version 2: addition of `loginsM.enc_unknown_fields`.
98pub(super) const VERSION: i64 = 2;
99
100/// Every column shared by both tables except for `id`
101///
102/// Note: `timeCreated`, `timeLastUsed`, and `timePasswordChanged` are in
103/// milliseconds. This is in line with how the server and Desktop handle it, but
104/// counter to how firefox-ios handles it (hence needing to fix them up
105/// firefox-ios on schema upgrade from 3, the last firefox-ios password schema
106/// version).
107///
108/// The reason for breaking from how firefox-ios does things is just because it
109/// complicates the code to have multiple kinds of timestamps, for very little
110/// benefit. It also makes it unclear what's stored on the server, leading to
111/// further confusion.
112///
113/// However, note that the `local_modified` (of `loginsL`) and `server_modified`
114/// (of `loginsM`) are stored as milliseconds as well both on firefox-ios and
115/// here (and so they do not need to be updated with the `timeLastUsed`/
116/// `timePasswordChanged`/`timeCreated` timestamps.
117pub const COMMON_COLS: &str = "
118    guid,
119    secFields,
120    origin,
121    httpRealm,
122    formActionOrigin,
123    usernameField,
124    passwordField,
125    timeCreated,
126    timeLastUsed,
127    timePasswordChanged,
128    timesUsed
129";
130
131const COMMON_SQL: &str = "
132    id                  INTEGER PRIMARY KEY AUTOINCREMENT,
133    origin              TEXT NOT NULL,
134    -- Exactly one of httpRealm or formActionOrigin should be set
135    httpRealm           TEXT,
136    formActionOrigin    TEXT,
137    usernameField       TEXT,
138    passwordField       TEXT,
139    timesUsed           INTEGER NOT NULL DEFAULT 0,
140    timeCreated         INTEGER NOT NULL,
141    timeLastUsed        INTEGER,
142    timePasswordChanged INTEGER NOT NULL,
143    secFields           TEXT,
144    guid                TEXT NOT NULL UNIQUE
145";
146
147lazy_static! {
148    static ref CREATE_LOCAL_TABLE_SQL: String = format!(
149        "CREATE TABLE IF NOT EXISTS loginsL (
150            {common_sql},
151            -- Milliseconds, or NULL if never modified locally.
152            local_modified INTEGER,
153
154            is_deleted     TINYINT NOT NULL DEFAULT 0,
155            sync_status    TINYINT NOT NULL DEFAULT 0
156        )",
157        common_sql = COMMON_SQL
158    );
159    static ref CREATE_MIRROR_TABLE_SQL: String = format!(
160        "CREATE TABLE IF NOT EXISTS loginsM (
161            {common_sql},
162            -- Milliseconds (a sync15::ServerTimestamp multiplied by
163            -- 1000 and truncated)
164            server_modified INTEGER NOT NULL,
165            is_overridden   TINYINT NOT NULL DEFAULT 0,
166            -- fields on incoming records we don't know about and roundtrip.
167            -- a serde_json::Value::Object as an encrypted string.
168            enc_unknown_fields   TEXT
169        )",
170        common_sql = COMMON_SQL
171    );
172    static ref SET_VERSION_SQL: String =
173        format!("PRAGMA user_version = {version}", version = VERSION);
174}
175
176const CREATE_META_TABLE_SQL: &str = "
177    CREATE TABLE IF NOT EXISTS loginsSyncMeta (
178        key TEXT PRIMARY KEY,
179        value NOT NULL
180    )
181";
182
183const CREATE_OVERRIDE_ORIGIN_INDEX_SQL: &str = "
184    CREATE INDEX IF NOT EXISTS idx_loginsM_is_overridden_origin
185    ON loginsM (is_overridden, origin)
186";
187
188const CREATE_DELETED_ORIGIN_INDEX_SQL: &str = "
189    CREATE INDEX IF NOT EXISTS idx_loginsL_is_deleted_origin
190    ON loginsL (is_deleted, origin)
191";
192
193pub(crate) static LAST_SYNC_META_KEY: &str = "last_sync_time";
194pub(crate) static GLOBAL_STATE_META_KEY: &str = "global_state_v2";
195pub(crate) static GLOBAL_SYNCID_META_KEY: &str = "global_sync_id";
196pub(crate) static COLLECTION_SYNCID_META_KEY: &str = "passwords_sync_id";
197pub(crate) static CHECKPOINT_KEY: &str = "checkpoint";
198
199pub(crate) fn init(db: &Connection) -> Result<()> {
200    let user_version = db.query_one::<i64>("PRAGMA user_version")?;
201    warn!("user_version: {}", user_version);
202    if user_version == 0 {
203        return create(db);
204    }
205    if user_version != VERSION {
206        if user_version < VERSION {
207            upgrade(db, user_version)?;
208        } else {
209            warn!(
210                "Loaded future schema version {} (we only understand version {}). \
211                 Optimistically ",
212                user_version, VERSION
213            )
214        }
215    }
216    Ok(())
217}
218
219// Allow the redundant Ok() here.  It will make more sense once we have an actual upgrade function.
220#[allow(clippy::unnecessary_wraps)]
221fn upgrade(db: &Connection, from: i64) -> Result<()> {
222    debug!("Upgrading schema from {} to {}", from, VERSION);
223    if from == VERSION {
224        return Ok(());
225    }
226    assert_ne!(
227        from, 0,
228        "Upgrading from user_version = 0 should already be handled (in `init`)"
229    );
230
231    // Schema upgrades.
232    if from == 1 {
233        // Just one new nullable column makes this fairly easy
234        db.execute_batch("ALTER TABLE loginsM ADD enc_unknown_fields TEXT;")?;
235    }
236    // XXX - next migration, be sure to:
237    // from = 2;
238    // if from == 2 ...
239    db.execute_batch(&SET_VERSION_SQL)?;
240    Ok(())
241}
242
243pub(crate) fn create(db: &Connection) -> Result<()> {
244    debug!("Creating schema");
245    db.execute_all(&[
246        &*CREATE_LOCAL_TABLE_SQL,
247        &*CREATE_MIRROR_TABLE_SQL,
248        CREATE_OVERRIDE_ORIGIN_INDEX_SQL,
249        CREATE_DELETED_ORIGIN_INDEX_SQL,
250        CREATE_META_TABLE_SQL,
251        &*SET_VERSION_SQL,
252    ])?;
253    Ok(())
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use crate::encryption::test_utils::TEST_ENCDEC;
260    use crate::LoginDb;
261    use nss::ensure_initialized;
262    use rusqlite::Connection;
263
264    #[test]
265    fn test_create_schema() {
266        ensure_initialized();
267        let db = LoginDb::open_in_memory();
268        // should be VERSION.
269        let version = db.query_one::<i64>("PRAGMA user_version").unwrap();
270        assert_eq!(version, VERSION);
271    }
272
273    #[test]
274    fn test_upgrade_v1() {
275        ensure_initialized();
276        // manually setup a V1 schema.
277        let connection = Connection::open_in_memory().unwrap();
278        connection
279            .execute_batch(
280                "
281                CREATE TABLE IF NOT EXISTS loginsM (
282                    -- this was common_sql as at v1
283                    id                  INTEGER PRIMARY KEY AUTOINCREMENT,
284                    origin              TEXT NOT NULL,
285                    httpRealm           TEXT,
286                    formActionOrigin    TEXT,
287                    usernameField       TEXT,
288                    passwordField       TEXT,
289                    timesUsed           INTEGER NOT NULL DEFAULT 0,
290                    timeCreated         INTEGER NOT NULL,
291                    timeLastUsed        INTEGER,
292                    timePasswordChanged INTEGER NOT NULL,
293                    secFields           TEXT,
294                    guid                TEXT NOT NULL UNIQUE,
295                    server_modified     INTEGER NOT NULL,
296                    is_overridden       TINYINT NOT NULL DEFAULT 0
297                    -- note enc_unknown_fields missing
298                );
299            ",
300            )
301            .unwrap();
302        // Call `create` to create the rest of the schema - the "if not exists" means loginsM
303        // will remain as v1.
304        create(&connection).unwrap();
305        // but that set the version to VERSION - set it back to 1 so our upgrade code runs.
306        connection
307            .execute_batch("PRAGMA user_version = 1;")
308            .unwrap();
309
310        // Now open the DB - it will create loginsL for us and migrate loginsM.
311        let db = LoginDb::with_connection(connection, TEST_ENCDEC.clone()).unwrap();
312        // all migrations should have succeeded.
313        let version = db.query_one::<i64>("PRAGMA user_version").unwrap();
314        assert_eq!(version, VERSION);
315
316        // and ensure sql selecting the new column works.
317        db.execute_batch("SELECT enc_unknown_fields FROM loginsM")
318            .unwrap();
319    }
320}