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}