All checks were successful
ci / test (push) Successful in 4m11s
App-level token bucket on rend accepts (30/60s, burst 10), panic-safe TUI restore, sqlite WAL + integrity_check fail-closed. Encrypted owbak1 identity backup/restore. README and threat model cover profile, XMR sidecar, backup, mixed-version frames.
135 lines
4.1 KiB
Rust
135 lines
4.1 KiB
Rust
//! Encrypted identity backup: owbak1 || salt[16] || nonce[12] || ciphertext.
|
|
|
|
use onionwire::Store;
|
|
use onionwire::backup::{self, BackupKeys};
|
|
use onionwire::tui::{
|
|
BackupDecision, BackupPrompt, SlashCmd, backup_screen_text, parse_cmd, restore_screen_text,
|
|
};
|
|
|
|
fn keys(tag: u8) -> BackupKeys {
|
|
BackupKeys {
|
|
identity_sk: [tag; 32],
|
|
identity_pk: [tag.wrapping_add(1); 32],
|
|
prekey_sk: [tag.wrapping_add(2); 32],
|
|
prekey_pk: [tag.wrapping_add(3); 32],
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn roundtrip_keys() {
|
|
let k = keys(7);
|
|
let blob = backup::seal("correct horse", &k).expect("seal");
|
|
assert!(blob.starts_with(b"owbak1"), "magic");
|
|
assert_eq!(&blob[0..6], b"owbak1");
|
|
let opened = backup::open("correct horse", &blob).expect("open");
|
|
assert_eq!(opened.identity_sk, k.identity_sk);
|
|
assert_eq!(opened.identity_pk, k.identity_pk);
|
|
assert_eq!(opened.prekey_sk, k.prekey_sk);
|
|
assert_eq!(opened.prekey_pk, k.prekey_pk);
|
|
}
|
|
|
|
#[test]
|
|
fn onion_is_not_in_backup() {
|
|
let k = keys(3);
|
|
let blob = backup::seal("pw", &k).unwrap();
|
|
let onion = b"abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopq.onion";
|
|
assert!(
|
|
!blob.windows(onion.len()).any(|w| w == onion),
|
|
"locator must not be in the backup file"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wrong_passphrase_fails() {
|
|
let blob = backup::seal("right", &keys(1)).unwrap();
|
|
assert!(backup::open("wrong", &blob).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn garbage_file_fails() {
|
|
assert!(backup::open("pw", b"nope").is_err());
|
|
assert!(backup::open("pw", b"owbak1").is_err());
|
|
let mut blob = backup::seal("pw", &keys(2)).unwrap();
|
|
blob.push(0xff);
|
|
assert!(backup::open("pw", &blob).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn restore_overwrites_self_keys_friends_stay() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let store = Store::open_at(dir.path()).expect("open");
|
|
store
|
|
.upsert_friend(&[9u8; 32], "alice.onion", Some("alice"))
|
|
.unwrap();
|
|
let old = store.self_identity().unwrap();
|
|
let incoming = keys(42);
|
|
store
|
|
.replace_identity_keys(
|
|
&incoming.identity_sk,
|
|
&incoming.identity_pk,
|
|
&incoming.prekey_sk,
|
|
&incoming.prekey_pk,
|
|
)
|
|
.unwrap();
|
|
let me = store.self_identity().unwrap();
|
|
assert_eq!(me.identity_sk, incoming.identity_sk);
|
|
assert_eq!(me.identity_pk, incoming.identity_pk);
|
|
assert_eq!(me.prekey_sk, incoming.prekey_sk);
|
|
assert_eq!(me.prekey_pk, incoming.prekey_pk);
|
|
assert_eq!(me.onion, old.onion, "onion is locator, not restored");
|
|
assert_eq!(store.friend_count().unwrap(), 1);
|
|
let f = store.get_friend(&[9u8; 32]).unwrap().unwrap();
|
|
assert_eq!(f.petname.as_deref(), Some("alice"));
|
|
}
|
|
|
|
#[test]
|
|
fn slash_backup_restore() {
|
|
assert_eq!(
|
|
parse_cmd("/backup /tmp/id.owbak"),
|
|
Some(SlashCmd::Backup {
|
|
path: "/tmp/id.owbak".into()
|
|
})
|
|
);
|
|
assert_eq!(
|
|
parse_cmd(" /restore /tmp/id.owbak "),
|
|
Some(SlashCmd::Restore {
|
|
path: "/tmp/id.owbak".into()
|
|
})
|
|
);
|
|
assert_eq!(parse_cmd("/backup"), None);
|
|
assert_eq!(parse_cmd("/restore"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn backup_requires_typing_not_enter() {
|
|
let mut p = BackupPrompt::backup();
|
|
assert_eq!(p.on_esc(), BackupDecision::Cancel);
|
|
assert_eq!(p.on_char('\n'), BackupDecision::Pending);
|
|
for c in "BACKU".chars() {
|
|
assert_eq!(p.on_char(c), BackupDecision::Pending);
|
|
}
|
|
assert_eq!(p.on_char('P'), BackupDecision::Confirm);
|
|
}
|
|
|
|
#[test]
|
|
fn restore_requires_typing_restore() {
|
|
let mut p = BackupPrompt::restore();
|
|
assert_eq!(p.on_char('\n'), BackupDecision::Pending);
|
|
for c in "RESTOR".chars() {
|
|
assert_eq!(p.on_char(c), BackupDecision::Pending);
|
|
}
|
|
assert_eq!(p.on_char('E'), BackupDecision::Confirm);
|
|
}
|
|
|
|
#[test]
|
|
fn restore_screen_warns_roster_stays() {
|
|
let t = restore_screen_text();
|
|
assert!(t.contains("does not rewrite the roster"), "{t}");
|
|
assert!(t.contains("Type RESTORE to confirm"), "{t}");
|
|
let b = backup_screen_text();
|
|
assert!(
|
|
t.contains("different person") || b.contains("identity"),
|
|
"{t}\n{b}"
|
|
);
|
|
assert!(b.contains("Type BACKUP to confirm"), "{b}");
|
|
}
|