Skip to content

feat(auth): device pairing, self-revoke, and usable file RPCs - #85

Merged
calvinleng-science merged 13 commits into
mainfrom
calvin/grpc-auth
Oct 12, 2026
Merged

calvinleng-science merged 13 commits into
mainfrom
calvin/grpc-auth

Conversation

@calvinleng-science

@calvinleng-science calvinleng-science commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds device pairing (a human at the device approves each computer before it can control it) and makes the file RPCs able to carry real device files, so file transfer can move off SFTP onto the same authenticated channel.

Changes

  • New api/auth.proto, RequestAuth (server-streaming). A client sends AuthRequest{client_label}; the device answers with an AuthChallenge (a six-digit code shown both on the device screen and in the client), then an AuthResult once the person at the device allows or denies. On success the result carries a token, which the client sends on every later call as an authorization: Bearer <token> header. Info and RequestAuth stay callable without a token; everything else requires one.
  • RevokeAuth. Revokes the token the call carries, i.e. the caller's own. Listing, revoking or changing the expiry of other paired clients is deliberately not in the API: that happens on the device itself, so a paired client cannot extend its own access or lock others out.
  • api/files.proto, file RPCs made usable:
    • WriteFile is now client-streaming. As a unary call with one bytes field it could not exceed gRPC's 4 MB default message size.
    • ListFiles takes a ListFilesRequest{path, recursive} instead of Empty, so a directory can actually be listed.
    • name → path throughout; ReadFileRequest.start_offset for resuming; DeleteFileRequest.recursive.
    • Sizes and timestamps are uint64. The largest recording measured on a device was 3.39 GB, within 21% of uint32's ceiling.
    • ListFilesResponse.File.is_dir is field 6, with 5 reserved: 5 was string type, and reusing it for a bool would change its wire type under any client generated against the old message.
  • VERSION → 2.6.0.

Testing

  • Server: sciencecorp/headstage calvin/grpc-auth (scifi-server). Reference client: sciencecorp/synapse-python calvin/grpc-auth (synapsectl pair, synapsectl unpair --uri, synapsectl file ls/get/put/rm).
  • Verified end to end on two SciFi devices: pairing and gating, revoke, and file transfer (an 800 MB upload byte-identical at 17.7 MB/s with flat server memory).

The pairing token travels in an "authorization: Bearer <token>" header
(RFC 6750) rather than a bespoke x- key, so proxies and observability
tooling that already understand bearer tokens keep working. This comment
is the closest thing the contract has to a spec, since each client
hardcodes the header name independently.
These RPCs existed in the proto but had no server implementation, so
nothing depended on their shape. Revising them now, before anything
does, is free; revising them later would not be.

WriteFile becomes client-streaming. As a unary call with a bytes field
it could not exceed gRPC's 4 MB default message limit, which made it
unusable for anything this device produces.

Sizes widen to uint64 and timestamps to int64. The largest recording
measured on a device is 3.39 GB -- already within 21% of uint32's 4 GB
ceiling, so a slightly longer run would have silently reported a
truncated size. The uint32 timestamps would have broken in 2038.

ListFiles takes a request rather than Empty, so a path can be listed at
all, and files carry a path relative to the data root plus is_dir, so a
result can be passed straight back to ReadFile or DeleteFile. ReadFile
gains a resume offset; DeleteFile requires an explicit recursive flag.

Paths are relative to the data root and must stay inside it. SFTP served
the whole filesystem to any authenticated user; this does not.
Field 5 was `string type`, which shipped in
@science-corporation/synapse 2.4.0, so generated clients expecting a
string there already exist. Writing `bool is_dir = 5` over it changed
the wire type on that slot: a bool is a varint, a string is
length-delimited, so an old client would hit a wire-type mismatch
rather than ignoring a field it does not recognise.

is_dir moves to 6 and 5 is reserved by number and by name, so nothing
can claim it again. A new server's response now carries no field 5 at
all, which an old client skips as an unknown field.

Checked the rest of the message set the same way. Nothing else changes
a wire type: the uint32 -> uint64/int64 widenings on size, created,
modified, start_offset and file_total_length are varint on both sides,
and the name -> path renames keep their numbers and types, since names
are not on the wire.
ListAuthClients and RevokeAuthClient let a paired client (in practice the
device's touchscreen) see which computers hold access and revoke one. The
touchscreen's own client is never listed.
SetAuthClientLifetime changes one paired client's lifetime (days from
approval, 0 = never), and AuthClient reports it.
Plainer field comments, a one-line note on the reserved slot, and uint64
for timestamps like every other time field in the API (wire-compatible
with int64 for the non-negative values sent).
ListAuthClients, RevokeAuthClient and SetAuthClientLifetime let any paired
client list, revoke or extend every other client. Managing paired clients
now happens on the device itself; the only remote call left is RevokeAuth,
which revokes the token it is called with.

@namthor9 namthor9 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

size, start_offset and file_total_length become size_bytes,
start_offset_bytes and file_size_bytes, so the unit is in the name like the
API's _ns/_ms time fields and available_disk_space_bytes. Field numbers are
unchanged, so this is wire-compatible.
@calvinleng-science
calvinleng-science merged commit f9fc77f into main Oct 12, 2026
2 checks passed
@calvinleng-science
calvinleng-science deleted the calvin/grpc-auth branch October 12, 2026 19:06
calvinleng-science added a commit to sciencecorp/synapse-python that referenced this pull request Oct 14, 2026
Adds device pairing to `synapsectl` and the Python client, and moves `synapsectl file` and `deploy-model` from SFTP to the gRPC file RPCs. Companion to sciencecorp/synapse-api#85 (merged), sciencecorp/synapse-typescript#56 (released as 2.5.0), and the SciFi firmware that serves these RPCs.

## Pairing

Newer SciFi firmware refuses every call except `Info` and `RequestAuth` until the caller is paired, and a person at the device approves each pairing.

* **`synapsectl pair`** asks the device for access. The device's screen and the terminal both show the same six-digit code; the person at the device compares them and taps Allow. The token is saved in `~/.scifi-env` (mode 0600, written atomically), keyed on the device's serial.
* **Every call carries the token automatically.** A client interceptor looks up the token for the device's serial on the first gated call and sends it as `authorization: Bearer <token>`. Open calls never trigger the lookup.
* **`synapsectl unpair --uri <device>`** revokes this computer's access on the device (`RevokeAuth`), then forgets the token. `unpair <serial-or-name>` only forgets the local token, for a device that is gone.

## File transfer over gRPC

SFTP needed its own password, so a device carried two credentials. The file RPCs are governed by the same pairing token as everything else, so the SFTP password goes away on firmware that serves them; the reachable tree is the same data directory the SFTP account was confined to.

* `synapsectl file ls | get | put | rm`, now over gRPC. `put` streams in 1 MiB chunks (a single message could not exceed gRPC's 4 MB limit); `get` streams too.
* **Safe interrupted transfers.** Downloads land in `<name>.partial` and are renamed into place only when complete, so a cancelled download never leaves a half-written file under the real name or touches an existing copy. `get` resumes a `.partial`, but only if the device's file is unchanged since (its size and modified time are recorded in `<name>.partial.json`); otherwise it starts over rather than splicing two files together. When interrupted it says where the partial is.
* **Asks before overwriting**, locally on `get` and on the device on `put` (default no, `-y` to skip the prompt). `get` of a missing file or a directory says so plainly and exits non-zero.
* `ls` hides dotfiles (the device keeps `.scifi-checked` markers beside recordings) unless `-a`.
* **SFTP fallback for older device firmware.** If the device answers the file RPCs with `UNIMPLEMENTED`, `file ls | get | put | rm` and `deploy-model` say the device firmware predates pairing and should be updated, then use the previous SFTP path with the device's file-transfer password. `--username`, `--env-file` and `--forget-password` are live again for that path. The fallback asks before overwriting and before `rm -r`, like the gRPC path. On that firmware the SFTP root is not writable, so a top-level upload says to use a folder such as `disk_writer/` or `models/`.
* **`deploy-model`** uploads over gRPC too. The device creates parent directories itself, so the separate mkdir/stat steps are gone.

Version → **2.8.0**. Tracks synapse-api `main` (`f9fc77f`).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants