Skip to main content

recall_wire/
devices.rs

1//! Devices: how a machine enrols, and how the owner approves, lists and
2//! revokes machines and the keys cloud sessions enrol with.
3//!
4//! Enrolment is OAuth's device flow (RFC 8628) with a key pair in place of
5//! a token. A machine generates an Ed25519 key, sends the public half to
6//! [`ENROLL_PATH`], and gets a short code. The owner approves the code
7//! somewhere already trusted, while the machine polls [`ENROLL_POLL_PATH`];
8//! once approved, the poll answers with the machine's device id, and from
9//! then on the machine signs its requests (see [`crate::signature`]).
10//!
11//! A cloud session cannot wait for anyone, so it enrols with an
12//! [`EnrollRequest::authkey`] instead, the way a Tailscale auth key
13//! works: approved at once, `sync` scope only, and ephemeral if the key
14//! says so.
15//!
16//! Everything under `/v1/devices` except enrolling, polling and
17//! [`DEVICES_ME_PATH`], and everything under `/v1/authkeys`, needs the
18//! operator's `RECALL_TOKEN` or a device with [`SCOPE_ADMIN`].
19
20use serde::{Deserialize, Serialize};
21
22use crate::signature::{self, SignatureError, VerifyingKey};
23
24/// `POST`: start enrolling a device. Unauthenticated.
25pub const ENROLL_PATH: &str = "/v1/devices/enroll";
26
27/// `POST`: ask whether an enrolment was approved. Unauthenticated; the
28/// enrolment id is the secret.
29pub const ENROLL_POLL_PATH: &str = "/v1/devices/enroll/poll";
30
31/// `GET`: every device.
32pub const DEVICES_PATH: &str = "/v1/devices";
33
34/// `GET`: the device that signed the request, as the server knows it.
35pub const DEVICES_ME_PATH: &str = "/v1/devices/me";
36
37/// `POST`: approve a pending enrolment by its user code.
38pub const APPROVE_PATH: &str = "/v1/devices/approve";
39
40/// `POST`: refuse a pending enrolment by its user code.
41pub const DENY_PATH: &str = "/v1/devices/deny";
42
43/// `GET` lists authkeys, `POST` creates one.
44pub const AUTHKEYS_PATH: &str = "/v1/authkeys";
45
46/// `POST`: revoke the device `id`.
47pub fn revoke_device_path(id: &str) -> String {
48    format!("{DEVICES_PATH}/{id}/revoke")
49}
50
51/// `POST`: revoke the authkey `id`.
52pub fn revoke_authkey_path(id: &str) -> String {
53    format!("{AUTHKEYS_PATH}/{id}/revoke")
54}
55
56/// `GET`: what the enrolment waiting with `user_code` asked for, so the
57/// approver can compare it with what the machine shows before approving.
58pub fn pending_path(user_code: &str) -> String {
59    format!("{DEVICES_PATH}/pending/{user_code}")
60}
61
62/// A device that may push and pull.
63pub const SCOPE_SYNC: &str = "sync";
64
65/// A device that may also approve, list and revoke devices and enrolment
66/// keys. It includes [`SCOPE_SYNC`].
67pub const SCOPE_ADMIN: &str = "admin";
68
69/// A device that may claim merge jobs and post their results, and nothing
70/// else: not push, not pull, not manage devices. It is how `recall-worker`
71/// is enrolled; see [`crate::jobs`]. An authkey never makes one.
72pub const SCOPE_WORKER: &str = "worker";
73
74/// How long a user code stays valid, in seconds: fifteen minutes, as
75/// GitHub's device flow gives.
76pub const CODE_TTL_SECONDS: u64 = 900;
77
78/// How often a machine may poll, in seconds (RFC 8628 §3.2's default).
79pub const POLL_INTERVAL_SECONDS: u64 = 5;
80
81/// What every authkey starts with, so one found in a log or a
82/// secret scanner's report says what it is.
83pub const AUTHKEY_PREFIX: &str = "recall-ak-";
84
85/// The characters a user code is made of: RFC 8628 §6.1's base-20 set,
86/// consonants only, so no code spells a word and none needs a shift key.
87pub const USER_CODE_ALPHABET: &[u8; 20] = b"BCDFGHJKLMNPQRSTVWXZ";
88
89/// The longest device name accepted, in characters.
90pub const MAX_NAME_CHARS: usize = 64;
91
92/// The longest `agent` accepted, in characters.
93pub const MAX_AGENT_CHARS: usize = 256;
94
95/// The longest authkey tag accepted, in characters. A device an
96/// authkey enrols is named after the tag, so it is kept short.
97pub const MAX_TAG_CHARS: usize = 32;
98
99/// The longest an authkey may live, in days.
100pub const MAX_AUTHKEY_DAYS: u32 = 365;
101
102/// How many unrevoked devices an authkey may have enrolled at once
103/// when it was made without saying. Enough for a day of cloud sessions,
104/// each an ephemeral device until it has been idle a day; few enough that
105/// a leaked key cannot mint devices without end. There is no key without
106/// a limit, only one made with a higher one.
107pub const DEFAULT_MAX_DEVICES: u32 = 25;
108
109/// RFC 8628 §3.5: not approved yet; poll again after the interval.
110pub const AUTHORIZATION_PENDING: &str = "authorization_pending";
111/// RFC 8628 §3.5: not approved yet, and polled too soon; add five seconds
112/// to the interval, for this and every later poll.
113pub const SLOW_DOWN: &str = "slow_down";
114/// RFC 8628 §3.5: the code expired before anyone approved it.
115pub const EXPIRED_TOKEN: &str = "expired_token";
116/// RFC 8628 §3.5: the owner refused it, or revoked the device it made.
117pub const ACCESS_DENIED: &str = "access_denied";
118/// RFC 6749 §5.2: no enrolment has that id.
119pub const INVALID_GRANT: &str = "invalid_grant";
120
121/// Formats eight characters of [`USER_CODE_ALPHABET`] the way they are
122/// shown: `WDJB-MJHT`.
123///
124/// Reads what a person typed, too: case, the hyphen, spaces and any other
125/// character outside the alphabet are ignored, as RFC 8628 §6.1 suggests,
126/// so `wdjb mjht` is the same code. [`None`] unless exactly eight remain.
127pub fn normalize_user_code(input: &str) -> Option<String> {
128    let chars: Vec<char> = input
129        .chars()
130        .map(|c| c.to_ascii_uppercase())
131        .filter(|c| c.is_ascii() && USER_CODE_ALPHABET.contains(&(*c as u8)))
132        .collect();
133    if chars.len() != 8 {
134        return None;
135    }
136    let (a, b) = chars.split_at(4);
137    Some(format!(
138        "{}-{}",
139        a.iter().collect::<String>(),
140        b.iter().collect::<String>()
141    ))
142}
143
144/// Body of `POST /v1/devices/enroll`.
145#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
146pub struct EnrollRequest {
147    /// What the owner will see this machine as, such as `laptop`.
148    pub name: String,
149    /// The device's Ed25519 public key: 32 bytes, base64url, no padding.
150    pub public_key: String,
151    /// The client's `User-Agent`, recorded so the device list can show
152    /// which version each machine runs.
153    #[serde(default)]
154    pub agent: String,
155    /// An authkey, for a machine that cannot wait for approval. It
156    /// is approved at once, with `sync` scope, and named by the server
157    /// rather than by `name`: nobody approved it, so it may not choose a
158    /// name that passes for another machine's.
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub authkey: Option<String>,
161}
162
163/// Why an enrolment request was refused before anything was stored. The
164/// wording is what the server answers with.
165#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
166pub enum EnrollError {
167    /// No name, or no key.
168    #[error("name and public_key are required")]
169    Missing,
170    /// A name too long, or with a character that could hide what it says.
171    #[error("name must be at most 64 characters, with no control, format or invisible characters")]
172    Name,
173    /// An agent too long, or with a character that could hide what it says.
174    #[error(
175        "agent must be at most 256 characters, with no control, format or invisible characters"
176    )]
177    Agent,
178    /// Not an acceptable Ed25519 public key.
179    #[error("{0}")]
180    PublicKey(SignatureError),
181}
182
183/// Characters a name must not contain, beyond `char::is_control`'s: the
184/// ones that change how the rest of a string displays, or display as
185/// nothing at all, so `laptop` followed by a zero-width space, or a name
186/// reversed by U+202E, cannot pass for another.
187///
188/// General categories Cf (format), Zl and Zp (line and paragraph
189/// separators), as Python's `unicodedata` lists them for Unicode 14, with
190/// the Egyptian format-control block widened to what later versions
191/// added; then the invisible characters Unicode marks default-ignorable
192/// that are not Cf: the combining grapheme joiner, the Hangul fillers,
193/// the Khmer inherent vowels, the Mongolian variation selectors and the
194/// variation selectors; and the braille blank, which draws nothing.
195const HIDDEN: &[(u32, u32)] = &[
196    (0x00AD, 0x00AD),
197    (0x034F, 0x034F),
198    (0x0600, 0x0605),
199    (0x061C, 0x061C),
200    (0x06DD, 0x06DD),
201    (0x070F, 0x070F),
202    (0x0890, 0x0891),
203    (0x08E2, 0x08E2),
204    (0x115F, 0x1160),
205    (0x17B4, 0x17B5),
206    (0x180B, 0x180F),
207    (0x200B, 0x200F),
208    (0x2028, 0x202E),
209    (0x2060, 0x2064),
210    (0x2066, 0x206F),
211    (0x2800, 0x2800),
212    (0x3164, 0x3164),
213    (0xFE00, 0xFE0F),
214    (0xFEFF, 0xFEFF),
215    (0xFFA0, 0xFFA0),
216    (0xFFF9, 0xFFFB),
217    (0x110BD, 0x110BD),
218    (0x110CD, 0x110CD),
219    (0x13430, 0x1343F),
220    (0x1BCA0, 0x1BCA3),
221    (0x1D173, 0x1D17A),
222    (0xE0001, 0xE0001),
223    (0xE0020, 0xE007F),
224    (0xE0100, 0xE01EF),
225];
226
227/// Whether `c` is a control character, or one of the format and invisible
228/// characters a name may not contain: Unicode categories Cf, Zl and Zp,
229/// and the default-ignorable invisibles outside them.
230pub fn is_hidden(c: char) -> bool {
231    let cp = u32::from(c);
232    c.is_control() || HIDDEN.iter().any(|&(lo, hi)| (lo..=hi).contains(&cp))
233}
234
235/// Whether `text` is at most `max` characters, none of them
236/// [`is_hidden`]. Device names, agents and authkey tags are held to
237/// this, since each is shown to a person deciding what to trust.
238pub fn displayable(text: &str, max: usize) -> bool {
239    text.chars().count() <= max && !text.chars().any(is_hidden)
240}
241
242impl EnrollRequest {
243    /// Checks the request as both sides see it, and returns the key it
244    /// names.
245    pub fn validate(&self) -> Result<VerifyingKey, EnrollError> {
246        if self.name.trim().is_empty() || self.public_key.is_empty() {
247            return Err(EnrollError::Missing);
248        }
249        if !displayable(&self.name, MAX_NAME_CHARS) {
250            return Err(EnrollError::Name);
251        }
252        if !displayable(&self.agent, MAX_AGENT_CHARS) {
253            return Err(EnrollError::Agent);
254        }
255        signature::parse_public_key(&self.public_key).map_err(EnrollError::PublicKey)
256    }
257}
258
259/// `POST /v1/devices/enroll` without an authkey: RFC 8628 §3.2's
260/// device authorization response, with the enrolment id in the place of
261/// its `device_code`.
262#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
263pub struct EnrollPending {
264    /// What the machine polls with. A secret: whoever holds it learns the
265    /// device id once approved.
266    pub enrollment_id: String,
267    /// What the owner approves, formatted as [`normalize_user_code`] does.
268    pub user_code: String,
269    /// Seconds until the code expires.
270    pub expires_in: u64,
271    /// Seconds to wait between polls.
272    pub interval: u64,
273}
274
275/// `POST /v1/devices/enroll` with a valid authkey: approved at once.
276#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
277pub struct EnrollApproved {
278    /// The device id, which the machine signs with as `keyid`.
279    pub device_id: String,
280    /// The name the server gave it: the key's tag, a hyphen, and the start
281    /// of the device id.
282    pub name: String,
283    /// Always [`SCOPE_SYNC`].
284    pub scope: String,
285    /// Whether the server removes this device after it has been idle for
286    /// a while.
287    pub ephemeral: bool,
288}
289
290/// Body of `POST /v1/devices/enroll/poll`.
291#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
292pub struct EnrollPollRequest {
293    /// From [`EnrollPending`].
294    pub enrollment_id: String,
295}
296
297/// `POST /v1/devices/enroll/poll` once approved. Before that, the answer
298/// is a 400 whose `error` is one of RFC 8628's codes, such as
299/// [`AUTHORIZATION_PENDING`].
300#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
301pub struct EnrollPollResponse {
302    /// The device id, which the machine signs with as `keyid`.
303    pub device_id: String,
304    /// [`SCOPE_SYNC`] or [`SCOPE_ADMIN`], as the owner approved it.
305    pub scope: String,
306}
307
308fn default_scope() -> String {
309    SCOPE_SYNC.to_string()
310}
311
312/// Body of `POST /v1/devices/approve`.
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
314pub struct ApproveRequest {
315    /// The code the machine shows, in any case, with or without its
316    /// hyphen.
317    pub user_code: String,
318    /// [`SCOPE_SYNC`] unless given; [`SCOPE_ADMIN`] to let the device
319    /// manage others; [`SCOPE_WORKER`] for `recall-worker`.
320    #[serde(default = "default_scope")]
321    pub scope: String,
322    /// The key fingerprint the approver was shown, by the machine or by
323    /// `GET /v1/devices/pending/{user_code}`. When given, the approval is
324    /// refused unless the code's key has exactly this fingerprint, which
325    /// binds the approval to what the approver actually saw.
326    #[serde(default, skip_serializing_if = "Option::is_none")]
327    pub fingerprint: Option<String>,
328}
329
330/// Body of `POST /v1/devices/deny`.
331#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
332pub struct DenyRequest {
333    /// The code the machine shows.
334    pub user_code: String,
335}
336
337/// `GET /v1/devices/pending/{user_code}`: an enrolment still waiting, as
338/// the approver sees it before deciding. Showing enough here that a
339/// phished approval gets noticed is what RFC 8628 §5.4 asks for.
340#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
341pub struct PendingEnrollment {
342    /// The code, normalized.
343    pub user_code: String,
344    /// The name the machine asked to be known by.
345    pub name: String,
346    /// The `agent` it enrolled with.
347    pub agent: String,
348    /// Its key's fingerprint (see [`signature::fingerprint`]): the machine
349    /// shows the same one, and the two should match.
350    pub fingerprint: String,
351    /// Seconds until the code can no longer be approved.
352    pub expires_in: u64,
353}
354
355/// `POST /v1/devices/deny`: what was refused.
356#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
357pub struct DenyResponse {
358    /// The code, normalized.
359    pub user_code: String,
360    /// The name the machine asked to be known by.
361    pub name: String,
362    /// Always `true`.
363    pub denied: bool,
364}
365
366/// One device, as `GET /v1/devices` lists it and as approving or revoking
367/// one answers.
368///
369/// Absent timestamps and ids are `null`, never omitted, so every key is
370/// always there to read.
371#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
372pub struct Device {
373    /// `dev_` and 26 characters: the `keyid` it signs with.
374    pub id: String,
375    /// What the owner sees it as.
376    pub name: String,
377    /// [`SCOPE_SYNC`] or [`SCOPE_ADMIN`].
378    pub scope: String,
379    /// Whether it is removed after being idle for a while.
380    pub ephemeral: bool,
381    /// The `agent` it enrolled with.
382    pub agent: String,
383    /// See [`signature::fingerprint`]: what the machine showed when it
384    /// enrolled, to compare against.
385    pub fingerprint: String,
386    /// Its Ed25519 public key, base64url without padding.
387    pub public_key: String,
388    /// The authkey it enrolled with, or `null` when a person
389    /// approved it.
390    pub authkey_id: Option<String>,
391    /// When it was approved.
392    pub created_at: String,
393    /// When it last made a signed request, to within a minute; `null`
394    /// before its first.
395    pub last_seen: Option<String>,
396    /// When it was revoked; `null` while it is not.
397    pub revoked_at: Option<String>,
398}
399
400/// `GET /v1/devices/me`: the device that signed the request.
401#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
402pub struct DeviceIdentity {
403    /// Its id, the `keyid` it signed with.
404    pub device_id: String,
405    /// Its name.
406    pub name: String,
407    /// [`SCOPE_SYNC`] or [`SCOPE_ADMIN`].
408    pub scope: String,
409    /// Whether it is removed after being idle for a while.
410    pub ephemeral: bool,
411}
412
413/// `GET /v1/devices`.
414#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
415pub struct DeviceList {
416    /// Every device, newest first, revoked ones included.
417    pub devices: Vec<Device>,
418}
419
420/// Body of `POST /v1/authkeys`.
421#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
422pub struct AuthkeyRequest {
423    /// A label, such as `cloud`.
424    #[serde(default)]
425    pub tag: String,
426    /// How many days the key can enrol devices for, 1 to 365. Required:
427    /// there is no key that never expires.
428    pub expires_in_days: u32,
429    /// Whether devices enrolled with it are ephemeral. `true` unless given:
430    /// a key is for machines that come and go.
431    #[serde(default = "default_true")]
432    pub ephemeral: bool,
433    /// The most devices it may have enrolled and unrevoked at once, 1 or
434    /// more; [`DEFAULT_MAX_DEVICES`] when left out. An ephemeral device
435    /// swept for being idle frees its place.
436    #[serde(default, skip_serializing_if = "Option::is_none")]
437    pub max_devices: Option<u32>,
438}
439
440fn default_true() -> bool {
441    true
442}
443
444/// Body of `POST /v1/authkeys/{id}/revoke`, which may also be empty.
445#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
446pub struct AuthkeyRevokeRequest {
447    /// Also revoke every device the key enrolled. `false` unless given:
448    /// revoking a key on its own only stops new enrolments.
449    #[serde(default)]
450    pub revoke_devices: bool,
451}
452
453/// One authkey, without the key itself, which is shown only once.
454#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
455pub struct Authkey {
456    /// `ak_` and 16 characters. Not a secret.
457    pub id: String,
458    /// Its label.
459    pub tag: String,
460    /// Whether devices enrolled with it are ephemeral.
461    pub ephemeral: bool,
462    /// The most unrevoked devices it may have enrolled at once. This
463    /// server always says; a `null` would mean [`DEFAULT_MAX_DEVICES`].
464    pub max_devices: Option<u32>,
465    /// When it was made.
466    pub created_at: String,
467    /// When it stops enrolling devices.
468    pub expires_at: String,
469    /// When it was revoked; `null` while it is not.
470    pub revoked_at: Option<String>,
471}
472
473/// `POST /v1/authkeys`: the new key, the one time it is ever shown.
474#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
475pub struct AuthkeyCreated {
476    /// As in [`Authkey`].
477    pub id: String,
478    /// The key: [`AUTHKEY_PREFIX`] and 52 characters. The server keeps
479    /// only its SHA-256.
480    pub key: String,
481    /// As in [`Authkey`].
482    pub tag: String,
483    /// As in [`Authkey`].
484    pub ephemeral: bool,
485    /// As in [`Authkey`].
486    pub max_devices: Option<u32>,
487    /// As in [`Authkey`].
488    pub created_at: String,
489    /// As in [`Authkey`].
490    pub expires_at: String,
491}
492
493/// `GET /v1/authkeys`.
494#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
495pub struct AuthkeyList {
496    /// Every authkey, newest first, revoked and expired ones
497    /// included.
498    pub authkeys: Vec<Authkey>,
499}
500
501/// The `devices` capability in the discovery document: present when the
502/// server enrols devices and accepts their signatures.
503#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
504pub struct DevicesCapability {
505    /// Where to start enrolling: [`ENROLL_PATH`].
506    pub enroll_path: String,
507    /// How long a user code lives: [`CODE_TTL_SECONDS`].
508    pub code_ttl_seconds: u64,
509    /// How often to poll: [`POLL_INTERVAL_SECONDS`].
510    pub poll_interval_seconds: u64,
511    /// How far a signature's `created` may be behind the server's clock:
512    /// [`signature::WINDOW_SECONDS`]. Ahead of it, it may be only
513    /// [`signature::MAX_AHEAD_SECONDS`].
514    pub signature_window_seconds: u64,
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520
521    #[test]
522    fn user_codes_are_read_the_way_rfc8628_suggests() {
523        assert_eq!(
524            normalize_user_code("WDJB-MJHT").as_deref(),
525            Some("WDJB-MJHT")
526        );
527        assert_eq!(
528            normalize_user_code("wdjbmjht").as_deref(),
529            Some("WDJB-MJHT")
530        );
531        assert_eq!(
532            normalize_user_code(" wdjb mjht ").as_deref(),
533            Some("WDJB-MJHT")
534        );
535        // Vowels and digits are not in the alphabet, so they are dropped,
536        // and what is left is too short.
537        assert_eq!(normalize_user_code("WDJB-MJHA"), None);
538        assert_eq!(normalize_user_code("WDJB-MJH"), None);
539        assert_eq!(normalize_user_code("WDJB-MJHTB"), None);
540        assert_eq!(normalize_user_code(""), None);
541    }
542
543    #[test]
544    fn enrolment_requests_are_checked_the_same_way_on_both_sides() {
545        let key = "JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs";
546        let ok = EnrollRequest {
547            name: "laptop".into(),
548            public_key: key.into(),
549            agent: "recall/0.4.1 (macos-aarch64)".into(),
550            authkey: None,
551        };
552        assert!(ok.validate().is_ok());
553        for (req, want) in [
554            (
555                EnrollRequest {
556                    name: "  ".into(),
557                    ..ok.clone()
558                },
559                EnrollError::Missing,
560            ),
561            (
562                EnrollRequest {
563                    public_key: String::new(),
564                    ..ok.clone()
565                },
566                EnrollError::Missing,
567            ),
568            (
569                EnrollRequest {
570                    name: "x".repeat(65),
571                    ..ok.clone()
572                },
573                EnrollError::Name,
574            ),
575            (
576                EnrollRequest {
577                    name: "lap\ntop".into(),
578                    ..ok.clone()
579                },
580                EnrollError::Name,
581            ),
582            (
583                EnrollRequest {
584                    agent: "a".repeat(257),
585                    ..ok.clone()
586                },
587                EnrollError::Agent,
588            ),
589            (
590                EnrollRequest {
591                    public_key: "short".into(),
592                    ..ok.clone()
593                },
594                EnrollError::PublicKey(SignatureError::PublicKey),
595            ),
596        ] {
597            assert_eq!(req.validate(), Err(want), "{req:?}");
598        }
599        // Sixty-four characters is the limit, not sixty-four bytes.
600        let wide = EnrollRequest {
601            name: "é".repeat(64),
602            ..ok
603        };
604        assert!(wide.validate().is_ok());
605    }
606
607    #[test]
608    fn approve_defaults_to_the_narrow_scope() {
609        let req: ApproveRequest = serde_json::from_str(r#"{"user_code":"WDJB-MJHT"}"#).unwrap();
610        assert_eq!(req.scope, SCOPE_SYNC);
611    }
612
613    #[test]
614    fn an_authkey_request_needs_an_expiry() {
615        assert!(serde_json::from_str::<AuthkeyRequest>(r#"{"tag":"cloud"}"#).is_err());
616        let req: AuthkeyRequest = serde_json::from_str(r#"{"expires_in_days":90}"#).unwrap();
617        assert_eq!(req.tag, "");
618        assert!(req.ephemeral, "a key's devices are ephemeral unless asked");
619        assert_eq!(req.max_devices, None);
620        let req: AuthkeyRevokeRequest = serde_json::from_str("{}").unwrap();
621        assert!(!req.revoke_devices);
622    }
623
624    /// Each character that could make one name pass for another: a
625    /// zero-width space, a right-to-left override, an isolate, a byte order
626    /// mark, a Hangul filler, a soft hyphen, a variation selector, a tag
627    /// character, a line separator, a bell.
628    #[test]
629    fn a_name_cannot_hide_characters() {
630        for hidden in [
631            '\u{200B}',
632            '\u{202E}',
633            '\u{2066}',
634            '\u{FEFF}',
635            '\u{3164}',
636            '\u{00AD}',
637            '\u{FE0F}',
638            '\u{E0041}',
639            '\u{2028}',
640            '\u{0007}',
641        ] {
642            let name = format!("lap{hidden}top");
643            assert!(
644                !displayable(&name, MAX_NAME_CHARS),
645                "{:04X}",
646                u32::from(hidden)
647            );
648        }
649        for fine in [
650            "laptop",
651            "Pim's MacBook Air",
652            "büro-rechner",
653            "ノートPC",
654            "laptop 2",
655        ] {
656            assert!(displayable(fine, MAX_NAME_CHARS), "{fine}");
657        }
658    }
659
660    #[test]
661    fn device_nulls_are_sent_not_omitted() {
662        let text = serde_json::to_string(&Device::default()).unwrap();
663        for key in ["authkey_id", "last_seen", "revoked_at"] {
664            assert!(text.contains(&format!("\"{key}\":null")), "{text}");
665        }
666    }
667}