You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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-serviceexcludes, 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.
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.
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:
Not valid before/afteron real CA certs, sothe device can't reach an NTP-over-HTTPS or update endpoint even after Wi-Fi
comes up.
field debugging of exactly the flows we care about (
ConnectWififailures)much harder.
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) fromnetprovdas a second GATT service alongside the netprov service, with awritable Current Time characteristic (
0x2A2B). The client writes thecurrent 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'sApplicationalready takes aVec<Service>, so this is anadditional entry in that vec — no restructuring:
Add
on_time_read/on_time_writetoGattHandlersnext to the existingfour. The write handler takes
req.device_addresslikeon_auth_writedoes,so
run_ble_servercan route it to that peer'sPeerSession.Notify on
0x2A2Bis optional per the CTS spec and we have no reason to pushtime 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_writeonly gets us link-layer bonding —the same bar the
Requestcharacteristic has, which is not on its own thenetprov trust boundary.
So
PeerSession::on_set_timemust checkself.session.lock().unwrap() .is_authenticated()first and returnReqError::NotAuthorizedotherwise,exactly like
on_requestdrops frames from unauthenticated peers. This doesmean a generic CTS client can't set the time without the PSK — a deliberate
trade, and worth stating in the README.
Reads of
0x2A2Bcan stay unauthenticated (it leaks nothing the advertisementdoesn't).
3. Value format
Current Time = 10 bytes, little-endian:
u16Parse/encode belongs in
crates/protocol(sayprotocol/src/cts.rs) so theSDK and server share one implementation and one set of tests.
chronoisalready 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. Untrustedinput 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). Aheadless Linux box wants UTC. Simplest resolution: document that netprov's own
client writes UTC in these fields and do not implement
0x2A0F; a genericthird-party time client will then set the clock to its local time, off by its
UTC offset. If that matters, implement
0x2A0Fand apply the offset. I'dstart 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
timedatedover D-Bus — recommended.org.freedesktop.timedate1.SetTime(usec_utc: i64, relative: false, interactive: false)on/org/freedesktop/timedate1.zbusis already adependency under
live-nm, and this crate already talks D-Bus forNetworkManager, 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/adjtimehandling that tripspeople up. It also needs no
CAP_SYS_TIMEinnetprovd— the privileged workhappens in
timedated, gated by polkit.Caveats to handle:
SetTimefails with "Automatic time synchronization is enabled" when NTP ison. Either call
SetNTP(false, false)first, or (better) surface the errorto the client — if NTP is already working, the clock doesn't need us.
org.freedesktop.timedate1.set-timeif/whennetprovdstops running as root. Note it inpackaging/now, since Further systemd hardening for netprovd.service #24 isheading toward a
User=line.systemd-timesyncd/timedatedbeing present.Fine for our Debian target; worth a
dependsbump incrates/server/Cargo.toml.(b)
libc::clock_settime(CLOCK_REALTIME)+ RTC.Direct, no D-Bus, but:
clock_settimeis in systemd's@clocksyscall set,which
@system-serviceexcludes, sopackaging/netprovd.servicewouldneed
SystemCallFilter=@clock(andProtectClock=noif #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/rtc0or shelling out tohwclock --systohc, plus the localtime/UTC question by hand.(c)
timedatectl set-time/hwclocksubprocesses. Shortest to write,but string-formatted time into a subprocess with no structured errors, and
timedatectlneeds the same D-Bus path as (a) anyway. Skip.Whichever is chosen, put it behind a small trait or a
cfg-gated module sothe
mockfeature can assert "set_time was called with X" without touchingthe host clock — mirroring how
NetworkFacade/facade_mockalready work.No test may set the machine's real clock.
6. Discoverability
InfoPayload.supported_opsis a bitmap ofOpdiscriminants, so it doesn'tdescribe CTS. Rather than overload it, a client should just discover
0x1805in 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: discover0x1805/0x2A2Balongside the existingfour characteristics and add
set_time(&self, when: DateTime<Utc>). Make itoptional — an older daemon won't have the service, and that must not
fail
connect().crates/client: aset-timesubcommand, defaulting to "now".crates/app: sync time automatically right after a successful auth. This isthe 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 afield-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: extendsensitive_characteristics_require_encryptionto assertthe CTS write has
encrypt_authenticated_writeset.live-ble, socargo test -p netprov-server --features live-bleandcargo build -p netprov-server --features live-bleper CLAUDE.md.Out of scope
0x2A0F), Reference Time Information (0x2A14),Next DST Change Service — no consumer for them.
timesyncdconfiguration as part of provisioning. Plausible follow-uponce 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 channelis 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
dispatchas-is. Roughly oneOpvariant, onedispatcharm, one facademethod. 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.