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}