openvibers/openvibe.link Commits
Add credential import for owner-rotated device credentials (#8)
Changes
nnew file mode 100644@@@ -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")++ }++}@@@ -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 stdinopenvibe-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)@@@ -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.++### FramesEvery frame is one JSON object in one text message with the envelope fields at the top level:@@@ -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?)"))@@@ -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
new file mode 100644
new file mode 100644
@@ -0,0 +1,155 @@
@@ -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")
+ }
+}
@@ -1,6 +1,7 @@
@@ -1,6 +1,7 @@
// Command openvibe-node connects a computer, server, Raspberry Pi or robot to OpenVibe.
// 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 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 run run in the foreground (this is also what the service runs)
// openvibe-node install install and start the system service
// openvibe-node install install and start the system service
// openvibe-node uninstall stop and remove 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.
@@ -44,6 +45,7 @@ const usage = `openvibe-node %s: connect this device to OpenVibe.
Usage:
Usage:
openvibe-node pair <CODE> [--robot rob_…] [--name NAME] [--server URL] [--kind onboard|bridge] [--force]
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 run [--dry-run]
openvibe-node install [--user NAME]
openvibe-node install [--user NAME]
openvibe-node uninstall
openvibe-node uninstall
@@ -70,6 +72,11 @@ type globals struct {
@@ -70,6 +72,11 @@ type globals struct {
}
}
func run(args []string, stdout, stderr io.Writer) int {
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" {
if len(args) == 0 || args[0] == "-h" || args[0] == "--help" || args[0] == "help" {
fmt.Fprintf(stdout, usage, version)
fmt.Fprintf(stdout, usage, version)
return 0
return 0
@@ -117,6 +124,12 @@ func run(args []string, stdout, stderr io.Writer) int {
@@ -117,6 +124,12 @@ func run(args []string, stdout, stderr io.Writer) int {
return 2
return 2
}
}
err = cmdPair(g, pairArgs{code: positional[0], robot: *robot, name: *name, server: *server, kind: *kind, force: *force}, stdout)
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":
case "run":
err = cmdRun(g, *dryRun)
err = cmdRun(g, *dryRun)
case "install":
case "install":
@@ -278,6 +291,58 @@ func cmdPair(g *globals, a pairArgs, stdout io.Writer) error {
@@ -278,6 +291,58 @@ func cmdPair(g *globals, a pairArgs, stdout io.Writer) error {
return nil
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 {
func cmdRun(g *globals, dryRun bool) error {
if err := g.paths.Ensure(); err != nil {
if err := g.paths.Ensure(); err != nil {
return fmt.Errorf("cannot create %s (run with sudo, or use --home): %w", g.paths.StateDir, err)
return fmt.Errorf("cannot create %s (run with sudo, or use --home): %w", g.paths.StateDir, err)
@@ -62,12 +62,28 @@ arrives (Bot answers any frame on a not-yet-authenticated socket with `error` `b
@@ -62,12 +62,28 @@ arrives (Bot answers any frame on a not-yet-authenticated socket with `error` `b
| code / answer | meaning | the Node |
| code / answer | meaning | the Node |
|------------------------------|------------------------------------------------------|--------------------------------------------------------|
|------------------------------|------------------------------------------------------|--------------------------------------------------------|
| `4000` | a second connection with this credential replaced it | reconnects with the normal backoff |
| `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` |
| `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`,
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.
`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
### Frames
Every frame is one JSON object in one text message with the envelope fields at the top level:
Every frame is one JSON object in one text message with the envelope fields at the top level:
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
//
//
// Close codes: 4000 (another connection with this credential replaced this one) reconnects with the normal backoff;
// 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
// 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
// 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.
// absolute deadline_ms of a command.
@@ -280,8 +280,8 @@ func (l *Link) backoff(err error, attempt int) time.Duration {
@@ -280,8 +280,8 @@ func (l *Link) backoff(err error, attempt int) time.Duration {
}
}
var (
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?)")
errReplaced = errors.New("another connection with this device's credential replaced this one (is a second agent running with the same credential?)")
)
)
@@ -313,7 +313,7 @@ func TestRevokedRetriesAfterTenSecondsAndNeverLogsCredential(t *testing.T) {
@@ -313,7 +313,7 @@ func TestRevokedRetriesAfterTenSecondsAndNeverLogsCredential(t *testing.T) {
t.Fatalf("disconnect error %v", err)
t.Fatalf("disconnect error %v", err)
}
}
waitLastError(t, l, "pair again")
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)
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
time.Sleep(time.Second) // ten times BackoffMax: an ordinary reconnect would have dialed again by now