Skip to content

Add time synchronisation via the Current Time Service (CTS), including system RTC #32

Description

@rosterloh

Problem

A headless device provisioned by netprov often has no working clock at first
contact: no RTC battery, or an RTC that has never been set, and no network yet
(that's the whole point of the provisioning session). Everything downstream
suffers:

  • TLS handshakes fail against Not valid before/after on real CA certs, so
    the device can't reach an NTP-over-HTTPS or update endpoint even after Wi-Fi
    comes up.
  • Journal timestamps for the provisioning session are meaningless, which makes
    field debugging of exactly the flows we care about (ConnectWifi failures)
    much harder.
  • Any future expiry/validity logic in the protocol (nonce windows, cert-based
    auth) has no trustworthy base.

The provisioning client (phone or laptop) always has a good clock and is
already connected over an encrypted, authenticated BLE link. We should use it
as the time source.

Proposal

Expose the standard Bluetooth SIG Current Time Service (0x1805) from
netprovd as a second GATT service alongside the netprov service, with a
writable Current Time characteristic (0x2A2B). The client writes the
current time; the daemon sets the system clock and, when present, the RTC.

Using CTS rather than a bespoke characteristic buys interop with generic BLE
time clients (nRF Connect, Android/iOS time-sync utilities) for free, and the
value format is already specified so there is nothing to invent.

1. GATT surface (crates/server/src/ble/gatt.rs)

bluer's Application already takes a Vec<Service>, so this is an
additional entry in that vec — no restructuring:

Service {
    uuid: Uuid::from_u128(0x00001805_0000_1000_8000_00805f9b34fb),
    primary: true,
    characteristics: vec![Characteristic {
        uuid: Uuid::from_u128(0x00002a2b_0000_1000_8000_00805f9b34fb),
        read: Some(CharacteristicRead { read: true, fun: /* current time */, ..Default::default() }),
        write: Some(CharacteristicWrite {
            write: true,
            write_without_response: false,
            encrypt_authenticated_write: true,   // same bar as AuthResponse/Request
            method: CharacteristicWriteMethod::Fun(/* -> on_set_time(addr, value) */),
            ..Default::default()
        }),
        ..Default::default()
    }],
    ..Default::default()
}

Add on_time_read / on_time_write to GattHandlers next to the existing
four. The write handler takes req.device_address like on_auth_write does,
so run_ble_server can route it to that peer's PeerSession.

Notify on 0x2A2B is optional per the CTS spec and we have no reason to push
time changes to a central — skip it.

2. Auth gating (crates/server/src/ble/conn.rs)

Setting a device's clock is a privileged operation: a hostile write of a
far-future time can silently expire certs, and a far-past write can revive
revoked ones. encrypt_authenticated_write only gets us link-layer bonding —
the same bar the Request characteristic has, which is not on its own the
netprov trust boundary.

So PeerSession::on_set_time must check self.session.lock().unwrap() .is_authenticated() first and return ReqError::NotAuthorized otherwise,
exactly like on_request drops frames from unauthenticated peers. This does
mean a generic CTS client can't set the time without the PSK — a deliberate
trade, and worth stating in the README.

Reads of 0x2A2B can stay unauthenticated (it leaks nothing the advertisement
doesn't).

3. Value format

Current Time = 10 bytes, little-endian:

Offset Field Notes
0–1 year u16 1582–9999, or 0 = unknown
2 month 1–12, 0 = unknown
3 day 1–31, 0 = unknown
4 hours 0–23
5 minutes 0–59
6 seconds 0–59
7 day of week 1 = Monday … 7 = Sunday, 0 = unknown
8 fractions256 1/256 s
9 adjust reason bitfield: manual / external ref / timezone / DST

Parse/encode belongs in crates/protocol (say protocol/src/cts.rs) so the
SDK and server share one implementation and one set of tests. chrono is
already in the tree via the workspace — check before adding anything.

Reject rather than coerce: any out-of-range field, wrong length, or an
unknown (0) year/month/day is an error, not a best-effort parse. Untrusted
input at a trust boundary.

Decision needed: CTS specifies Current Time as local time, with the UTC
offset carried separately in Local Time Information (0x2A0F, optional). A
headless Linux box wants UTC. Simplest resolution: document that netprov's own
client writes UTC in these fields and do not implement 0x2A0F; a generic
third-party time client will then set the clock to its local time, off by its
UTC offset. If that matters, implement 0x2A0F and apply the offset. I'd
start without it.

4. Sanity clamp

Before applying, reject times outside a plausible window — e.g. earlier than
the build/release date of the binary, or more than ~10 years ahead. Cheap, and
it stops both a fat-fingered client and the classic "1970 or 2106" garbage
write from bricking cert validation. Compile the lower bound in via
env!("VERGEN_BUILD_TIMESTAMP") or a plain hardcoded constant.

5. Applying it — system clock and RTC

Three options; I'd take the first.

(a) systemd timedated over D-Bus — recommended.
org.freedesktop.timedate1.SetTime(usec_utc: i64, relative: false, interactive: false) on /org/freedesktop/timedate1. zbus is already a
dependency under live-nm, and this crate already talks D-Bus for
NetworkManager, so it's ~15 lines and no new deps.

Why it wins: timedated syncs the RTC for us after setting the system
clock, so "write to the system RTC if available" is handled with zero extra
code, including the UTC-vs-local-RTC and /etc/adjtime handling that trips
people up. It also needs no CAP_SYS_TIME in netprovd — the privileged work
happens in timedated, gated by polkit.

Caveats to handle:

  • SetTime fails with "Automatic time synchronization is enabled" when NTP is
    on. Either call SetNTP(false, false) first, or (better) surface the error
    to the client — if NTP is already working, the clock doesn't need us.
  • Needs a polkit rule for org.freedesktop.timedate1.set-time if/when
    netprovd stops running as root. Note it in packaging/ now, since Further systemd hardening for netprovd.service #24 is
    heading toward a User= line.
  • Adds a runtime dependency on systemd-timesyncd/timedated being present.
    Fine for our Debian target; worth a depends bump in crates/server/Cargo.toml.

(b) libc::clock_settime(CLOCK_REALTIME) + RTC.
Direct, no D-Bus, but: clock_settime is in systemd's @clock syscall set,
which @system-service excludes, so packaging/netprovd.service would
need SystemCallFilter=@clock (and ProtectClock=no if #24 ever adds it) —
i.e. this option loosens the hardening that (a) leaves untouched. RTC then
needs either ioctl(RTC_SET_TIME) on /dev/rtc0 or shelling out to
hwclock --systohc, plus the localtime/UTC question by hand.

(c) timedatectl set-time / hwclock subprocesses. Shortest to write,
but string-formatted time into a subprocess with no structured errors, and
timedatectl needs the same D-Bus path as (a) anyway. Skip.

Whichever is chosen, put it behind a small trait or a cfg-gated module so
the mock feature can assert "set_time was called with X" without touching
the host clock — mirroring how NetworkFacade / facade_mock already work.
No test may set the machine's real clock.

6. Discoverability

InfoPayload.supported_ops is a bitmap of Op discriminants, so it doesn't
describe CTS. Rather than overload it, a client should just discover 0x1805
in the GATT service list — which is the standard mechanism and free. (See #21
for the separate problem that this bitmap is hardcoded to 0x7F.)

7. Client side

  • crates/sdk/src/ble.rs: discover 0x1805/0x2A2B alongside the existing
    four characteristics and add set_time(&self, when: DateTime<Utc>). Make it
    optional — an older daemon won't have the service, and that must not
    fail connect().
  • crates/client: a set-time subcommand, defaulting to "now".
  • crates/app: sync time automatically right after a successful auth. This is
    the main win — the user never thinks about it.

Tests

  • protocol: round-trip encode/decode; explicit rejection cases (short buffer,
    month 13, hour 24, year 0, 11-byte value).
  • protocol: known-good byte vector from the CTS spec, hand-checked, so a
    field-order slip can't pass.
  • server: unauthenticated CTS write → NotAuthorized, clock untouched;
    authenticated write → mock time-setter called with the expected instant.
  • server: out-of-clamp time rejected.
  • gatt.rs: extend sensitive_characteristics_require_encryption to assert
    the CTS write has encrypt_authenticated_write set.
  • Remember the feature slices — CTS server code lands under live-ble, so
    cargo test -p netprov-server --features live-ble and
    cargo build -p netprov-server --features live-ble per CLAUDE.md.

Out of scope

  • Local Time Information (0x2A0F), Reference Time Information (0x2A14),
    Next DST Change Service — no consumer for them.
  • Timezone configuration. Separate concern from "what time is it".
  • NTP/timesyncd configuration as part of provisioning. Plausible follow-up
    once the network is up, but not this issue.

Cheaper alternative, for the record

If interop with generic BLE time clients turns out not to matter, Op::SetTime { unix_secs: i64, nanos: u32 } on the existing authenticated request channel
is a much smaller diff: no second GATT service, no 10-byte binary layout, no
separate auth gate, and it reuses framing, HMAC auth, rate limiting, and
dispatch as-is. Roughly one Op variant, one dispatch arm, one facade
method. Worth a moment's thought before building the CTS version — the CTS
value here is precisely the interop, so if we don't want that, we're paying for
nothing.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions