openvibers/openvibe.link Commits

Add credential import for owner-rotated device credentials (#8)

OP OpenVibers committed 2 Oct 2026 · 966277a0c9 · 5 files, +241 −5

local checks ✗ 0/2 · CI ✓ 9/9 · review SHIP by DeepSeek harness · 1 fix round

https://github.com/OpenVibers/OpenVibe.Node/pull/8

Changes

ddiff --git a/cmd/openvibe-node/credential_test.go b/cmd/openvibe-node/credential_test.go
nnew file mode 100644
iindex 0000000..e804308
---- /dev/null
++++ b/cmd/openvibe-node/credential_test.go
@@@ -0,0 +1,155 @@
++package main
++
++import (
++ "bytes"
++ "os"
++ "path/filepath"
++ "runtime"
++ "strings"
++ "testing"
++
++ "github.com/OpenVibers/OpenVibe.Node/internal/credentials"
++)
++
++func importCLI(t *testing.T, home, stdin string) (string, string, int) {
++ t.Helper()
++ var out, errb bytes.Buffer
++ code := runWithStdin([]string{"credential", "import", "--home", home}, strings.NewReader(stdin), &out, &errb)
++ return out.String(), errb.String(), code
++}
++
++// rotateJSON is OpenVibe.Bot's POST /api/v1/devices/:id/rotate answer.
++func rotateJSON(deviceID, cred, pk string) string {
++ return `{"device":{"id":"` + deviceID + `","name":"Rover"},"credential":"` + cred + `","publish_key":"` + pk + `"}`
++}
++
++func TestCredentialImport(t *testing.T) {
++ home := shortHome(t)
++ path := filepath.Join(home, "credential.json")
++ old := &credentials.Credentials{
++ DeviceID: "dev_1", RobotID: "rob_1", Credential: credentials.NewSecret("old-cred"), PublishKey: credentials.NewSecret("old-pk"),
++ Server: "https://openvibe.bot", DeviceURL: "wss://openvibe.bot/device", WHIPURL: "https://ingest.openre.stream/whip/old",
++ }
++ if err := credentials.Save(path, old); err != nil {
++ t.Fatal(err)
++ }
++ out, errOut, code := importCLI(t, home, rotateJSON("dev_1", "new-cred", "new-pk"))
++ if code != 0 {
++ t.Fatalf("code %d: %s %s", code, out, errOut)
++ }
++ if !strings.Contains(out, "dev_1") || !strings.Contains(out, "restart") {
++ t.Fatalf("success line: %q", out)
++ }
++ if all := out + errOut; strings.Contains(all, "new-cred") || strings.Contains(all, "new-pk") || strings.Contains(all, "old-cred") || strings.Contains(all, "old-pk") {
++ t.Fatalf("a secret reached the output: %q %q", out, errOut)
++ }
++ got, err := credentials.Load(path, nil)
++ if err != nil {
++ t.Fatal(err)
++ }
++ if got.Credential.Reveal() != "new-cred" || got.PublishKey.Reveal() != "new-pk" {
++ t.Fatalf("secrets not replaced: %+v", got)
++ }
++ // Everything else is kept.
++ if got.DeviceID != "dev_1" || got.RobotID != "rob_1" || got.Server != old.Server || got.DeviceURL != old.DeviceURL || got.WHIPURL != old.WHIPURL {
++ t.Fatalf("a stored field was lost: %+v", got)
++ }
++ if runtime.GOOS != "windows" {
++ st, err := os.Stat(path)
++ if err != nil {
++ t.Fatal(err)
++ }
++ if st.Mode().Perm() != 0o600 {
++ t.Fatalf("mode %o", st.Mode().Perm())
++ }
++ }
++}
++
++func TestCredentialImportRefusesOtherDevice(t *testing.T) {
++ home := shortHome(t)
++ path := filepath.Join(home, "credential.json")
++ if err := credentials.Save(path, &credentials.Credentials{DeviceID: "dev_1", Credential: credentials.NewSecret("old-cred"), PublishKey: credentials.NewSecret("old-pk")}); err != nil {
++ t.Fatal(err)
++ }
++ before, err := os.ReadFile(path)
++ if err != nil {
++ t.Fatal(err)
++ }
++ out, errOut, code := importCLI(t, home, rotateJSON("dev_2", "new-cred", "new-pk"))
++ if code == 0 || !strings.Contains(errOut, "dev_2") || !strings.Contains(errOut, "dev_1") {
++ t.Fatalf("code %d: %s %s", code, out, errOut)
++ }
++ if all := out + errOut; strings.Contains(all, "new-cred") || strings.Contains(all, "new-pk") {
++ t.Fatalf("a secret reached the output: %q %q", out, errOut)
++ }
++ after, err := os.ReadFile(path)
++ if err != nil {
++ t.Fatal(err)
++ }
++ if !bytes.Equal(before, after) {
++ t.Fatal("the credential file changed on a refused import")
++ }
++}
++
++func TestCredentialImportRefusesWhenNotPaired(t *testing.T) {
++ home := shortHome(t)
++ out, errOut, code := importCLI(t, home, rotateJSON("dev_1", "new-cred", "new-pk"))
++ if code == 0 || !strings.Contains(errOut, "pair") {
++ t.Fatalf("code %d: %s %s", code, out, errOut)
++ }
++ if _, err := os.Stat(filepath.Join(home, "credential.json")); !os.IsNotExist(err) {
++ t.Fatalf("a credential file appeared: %v", err)
++ }
++ if strings.Contains(out+errOut, "new-cred") || strings.Contains(out+errOut, "new-pk") {
++ t.Fatalf("a secret reached the output: %q %q", out, errOut)
++ }
++}
++
++func TestCredentialImportRefusesIncompleteResponse(t *testing.T) {
++ home := shortHome(t)
++ path := filepath.Join(home, "credential.json")
++ if err := credentials.Save(path, &credentials.Credentials{DeviceID: "dev_1", Credential: credentials.NewSecret("old-cred"), PublishKey: credentials.NewSecret("old-pk")}); err != nil {
++ t.Fatal(err)
++ }
++ before, err := os.ReadFile(path)
++ if err != nil {
++ t.Fatal(err)
++ }
++ cases := []string{
++ // no publish_key
++ `{"device":{"id":"dev_1"},"credential":"new-cred"}`,
++ // no credential
++ `{"device":{"id":"dev_1"},"publish_key":"new-pk"}`,
++ // no device.id
++ `{"credential":"new-cred","publish_key":"new-pk"}`,
++ `not json`,
++ ``,
++ // over the read cap
++ strings.Repeat("a", maxRotateJSON+1),
++ }
++ for _, in := range cases {
++ out, errOut, code := importCLI(t, home, in)
++ if code == 0 {
++ t.Fatalf("accepted %q: %s", in, out)
++ }
++ if all := out + errOut; strings.Contains(all, "new-cred") || strings.Contains(all, "new-pk") {
++ t.Fatalf("a secret reached the output for %q: %q %q", in, out, errOut)
++ }
++ after, err := os.ReadFile(path)
++ if err != nil {
++ t.Fatal(err)
++ }
++ if !bytes.Equal(before, after) {
++ t.Fatalf("the credential file changed on %q", in)
++ }
++ }
++}
++
++func TestCredentialImportSubcommandUsage(t *testing.T) {
++ if _, errOut, code := cli(t, "credential"); code != 2 || !strings.Contains(errOut, "credential import") {
++ t.Fatalf("bare credential: %d %s", code, errOut)
++ }
++ if _, _, code := cli(t, "credential", "bogus"); code != 2 {
++ t.Fatal("unknown subcommand accepted")
++ }
++}
ddiff --git a/cmd/openvibe-node/main.go b/cmd/openvibe-node/main.go
iindex 048decd..68b0bf9 100644
---- a/cmd/openvibe-node/main.go
++++ b/cmd/openvibe-node/main.go
@@@ -1,6 +1,7 @@
// Command openvibe-node connects a computer, server, Raspberry Pi or robot to OpenVibe.
//
// openvibe-node pair <CODE> redeem a one-time pairing code and store the device credential
++// openvibe-node credential import take a rotated credential: reads the rotate response JSON on stdin
// openvibe-node run run in the foreground (this is also what the service runs)
// openvibe-node install install and start the system service
// openvibe-node uninstall stop and remove the system service
@@@ -44,6 +45,7 @@ const usage = `openvibe-node %s: connect this device to OpenVibe.
Usage:
openvibe-node pair <CODE> [--robot rob_…] [--name NAME] [--server URL] [--kind onboard|bridge] [--force]
++ openvibe-node credential import store the credential from a rotate response, read as JSON on stdin
openvibe-node run [--dry-run]
openvibe-node install [--user NAME]
openvibe-node uninstall
@@@ -70,6 +72,11 @@ type globals struct {
}
func run(args []string, stdout, stderr io.Writer) int {
++ return runWithStdin(args, os.Stdin, stdout, stderr)
++}
++
++// runWithStdin is run with the command's stdin, so tests can feed `credential import`.
++func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
if len(args) == 0 || args[0] == "-h" || args[0] == "--help" || args[0] == "help" {
fmt.Fprintf(stdout, usage, version)
return 0
@@@ -117,6 +124,12 @@ func run(args []string, stdout, stderr io.Writer) int {
return 2
}
err = cmdPair(g, pairArgs{code: positional[0], robot: *robot, name: *name, server: *server, kind: *kind, force: *force}, stdout)
++ case "credential":
++ if len(positional) != 1 || positional[0] != "import" {
++ fmt.Fprintln(stderr, "usage: openvibe-node credential import (reads the rotate response JSON on stdin)")
++ return 2
++ }
++ err = cmdCredentialImport(g, stdin, stdout)
case "run":
err = cmdRun(g, *dryRun)
case "install":
@@@ -278,6 +291,58 @@ func cmdPair(g *globals, a pairArgs, stdout io.Writer) error {
return nil
}
++// maxRotateJSON caps what `credential import` reads from stdin: Bot's rotate answer is a few hundred bytes.
++const maxRotateJSON = 64 << 10
++
++// cmdCredentialImport replaces the stored credential and publish key with the pair from a credential rotation.
++// It reads OpenVibe.Bot's `POST /api/v1/devices/:id/rotate` response from stdin and keeps every other stored field,
++// so a rotated Node does not have to pair again. Neither secret is ever printed.
++func cmdCredentialImport(g *globals, stdin io.Reader, stdout io.Writer) error {
++ // The rotate answer is small; refuse a huge stream instead of buffering it.
++ b, err := io.ReadAll(io.LimitReader(stdin, maxRotateJSON+1))
++ if err != nil {
++ return fmt.Errorf("reading the rotate response from stdin: %w", err)
++ }
++ if len(b) > maxRotateJSON {
++ return fmt.Errorf("the rotate response is over %d KiB; pipe just the POST /api/v1/devices/:id/rotate body", maxRotateJSON>>10)
++ }
++ // The plain strings below live only in this function; nothing formats them.
++ var resp struct {
++ Device struct {
++ ID string `json:"id"`
++ } `json:"device"`
++
++ Credential string `json:"credential"`
++ PublishKey string `json:"publish_key"`
++ }
++ // Never wrap the decoder error: it can quote the response body, secrets and all.
++ if err := json.Unmarshal(b, &resp); err != nil {
++ return errors.New("stdin is not the JSON that POST /api/v1/devices/:id/rotate answered with")
++ }
++ if resp.Credential == "" || resp.PublishKey == "" {
++ return errors.New("the rotate response has no credential or publish_key; pipe the whole response body")
++ }
++ old, err := credentials.Load(g.paths.CredentialFile(), nil)
++ if errors.Is(err, credentials.ErrNotPaired) {
++ return errors.New("this Node is not paired yet; run `openvibe-node pair <CODE>` first")
++ }
++ if err != nil {
++ return err
++ }
++ if resp.Device.ID != old.DeviceID {
++ return fmt.Errorf("this rotation is for device %s, this Node is paired as %s", resp.Device.ID, old.DeviceID)
++ }
++ next := *old
++ next.Credential = credentials.NewSecret(resp.Credential)
++ next.PublishKey = credentials.NewSecret(resp.PublishKey)
++ if err := credentials.Save(g.paths.CredentialFile(), &next); err != nil {
++ return err
++ }
++ chownLikeDir(g.paths.CredentialFile(), g.paths.ConfigDir)
++ fmt.Fprintf(stdout, "Imported the rotated credential for %s. The old credential stops working after 60 s; restart the node (or it reconnects on its own).\n", old.DeviceID)
++ return nil
++}
++
func cmdRun(g *globals, dryRun bool) error {
if err := g.paths.Ensure(); err != nil {
return fmt.Errorf("cannot create %s (run with sudo, or use --home): %w", g.paths.StateDir, err)
ddiff --git a/docs/protocol.md b/docs/protocol.md
iindex 2789e96..d664cd0 100644
---- a/docs/protocol.md
++++ b/docs/protocol.md
@@@ -62,12 +62,28 @@ arrives (Bot answers any frame on a not-yet-authenticated socket with `error` `b
| code / answer | meaning | the Node |
|------------------------------|------------------------------------------------------|--------------------------------------------------------|
| `4000` | a second connection with this credential replaced it | reconnects with the normal backoff |
--| `4002`, or HTTP `401`/`403` | the credential is wrong, rotated or revoked | retries no sooner than 10 s (jitter only adds), logs "pair again, or install the current credential" |
++| `4002`, or HTTP `401`/`403` | the credential is wrong, rotated or revoked | retries no sooner than 10 s (jitter only adds), logs "pair again, or import the owner's rotation" |
| `4003` | the owner revoked the device (or rotated its credential) while it was connected | the same as `4002` |
The link never gives up on its own: the owner may pair the device again. Bot's `error` frames (`bot.not_paired`,
`bot.not_ready`, `bot.forbidden`, …) are logged and never end the connection.
++### Rotating a credential
++
++The owner can rotate a device's credential in OpenVibe.Bot (`POST /api/v1/devices/:id/rotate`). Its answer is JSON
++with `device.id`, `credential` and `publish_key`, and the old credential keeps working for 60 s. Pipe that response
++body into the Node to take the new pair without pairing again:
++
++```sh
++curl -s -X POST -H "Authorization: Bearer $TOKEN" https://openvibe.bot/api/v1/devices/dev_…/rotate \
++ | openvibe-node credential import
++```
++
++`import` keeps every other stored field (server, WHIP URL, robot ids, profile, version), rewrites `credential.json`
++atomically at mode 0600 and never prints either secret. It refuses unless a credential file exists ("pair first") and
++unless the response's `device.id` is the device this Node is paired as. A running Node keeps using the old credential
++until it restarts; on a refused credential it logs the two remedies: pair again, or import the owner's rotation.
++
### Frames
Every frame is one JSON object in one text message with the envelope fields at the top level:
ddiff --git a/internal/link/link.go b/internal/link/link.go
iindex c7ae40a..e9b7d4f 100644
---- a/internal/link/link.go
++++ b/internal/link/link.go
@@@ -7,7 +7,7 @@
//
// Close codes: 4000 (another connection with this credential replaced this one) reconnects with the normal backoff;
// 4002 (credential refused) and 4003 (revoked) keep retrying, never sooner than CredentialRetryMin, and log what to
--// do: pair again, or install the current credential. Bot's error frames are logged, never fatal.
++// do: pair again, or import the owner's rotation. Bot's error frames are logged, never fatal.
//
// Every server frame's ts also feeds an estimate of the server's clock (ServerNow), which the Node uses to read the
// absolute deadline_ms of a command.
@@@ -280,8 +280,8 @@ func (l *Link) backoff(err error, attempt int) time.Duration {
}
var (
-- errUnauthorized = errors.New("the server refused the device credential (wrong, rotated or revoked): pair again with `openvibe-node pair <CODE>`, or install the device's current credential, then restart the Node")
-- errRevoked = errors.New("the owner revoked this device or rotated its credential: pair again with `openvibe-node pair <CODE>`, or install the device's current credential, then restart the Node")
++ errUnauthorized = errors.New("the server refused the device credential (wrong, rotated or revoked): pair again with `openvibe-node pair <CODE>`, or, if the owner rotated it, pipe the rotate response into `openvibe-node credential import`")
++ errRevoked = errors.New("the owner revoked this device or rotated its credential: pair again with `openvibe-node pair <CODE>`, or, if the owner rotated it, pipe the rotate response into `openvibe-node credential import`")
errReplaced = errors.New("another connection with this device's credential replaced this one (is a second agent running with the same credential?)")
)
ddiff --git a/internal/link/link_test.go b/internal/link/link_test.go
iindex 562541a..ba1ee91 100644
---- a/internal/link/link_test.go
++++ b/internal/link/link_test.go
@@@ -313,7 +313,7 @@ func TestRevokedRetriesAfterTenSecondsAndNeverLogsCredential(t *testing.T) {
t.Fatalf("disconnect error %v", err)
}
waitLastError(t, l, "pair again")
-- if !strings.Contains(l.Stats().LastError, "current credential") {
++ if !strings.Contains(l.Stats().LastError, "credential import") {
t.Fatalf("last error %q does not say how to recover", l.Stats().LastError)
}
time.Sleep(time.Second) // ten times BackoffMax: an ordinary reconnect would have dialed again by now