openvibers/openvibe.link Commits
Node: profile verify, and pairing checks a community robot profile against Bot's signature (#40)
Changes
@@@ -34,6 +34,12 @@ openvibe-node resume- **ESP32 library** ([`esp32/`](esp32/README.md), `OpenVibeNode`): the device side of the protocol for ESP32 boards —pairing, the control link, drive/actuator commands, telemetry, heartbeat, deadman and e-stop — but no jobs or video.++**Community robots:** a profile people submit on openvibe.bot (`community.<name>`) is reviewed by an OpenVibe admin++and signed by Bot (Ed25519 over its canonical JSON). `openvibe-node profile verify community.<name>` checks that++signature against the key Bot publishes (`--key-id` pins one) and names the plugin the robots run; pairing with a robot++on a community profile runs the same check and warns when it fails. Install a community plugin only when it passes++([docs/plugins.md](docs/plugins.md#community-plugins)).++**A machine that runs your jobs:** add it on openvibe.bot/machines, which prints the same installer with `--worker`: thejob worker runs each job as an unprivileged user in a sandbox, and the OpenVibe.Codes job runner lets you start a codingtask on openvibe.codes/run from any browser and have this machine do it ([docs/worker.md](docs/worker.md)).@@@ -34,6 +34,7 @@ import ("github.com/OpenVibers/OpenVibe.Node/internal/localctl""github.com/OpenVibers/OpenVibe.Node/internal/node""github.com/OpenVibers/OpenVibe.Node/internal/plugins"++ "github.com/OpenVibers/OpenVibe.Node/internal/profilesig""github.com/OpenVibers/OpenVibe.Node/internal/protocol""github.com/OpenVibers/OpenVibe.Node/internal/safety""github.com/OpenVibers/OpenVibe.Node/internal/service"@@@ -56,6 +57,8 @@ Usage:openvibe-node stop stop every actuator now and hold them stoppedopenvibe-node resume release the stopopenvibe-node plugins [--json]++ openvibe-node profile verify community.<name> [--server URL] [--key-id ID]++ check a community robot profile against the signature Bot made when it was reviewedopenvibe-node versionGlobal flags (before or after the command):@@@ -103,6 +106,7 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) intasJSON = fs.Bool("json", false, "print JSON (status, plugins)")svcUser = fs.String("user", "", "run the service as this account (install)")session = fs.Bool("user-session", false, "Windows: start at sign-in in your own session instead of as a service (install, uninstall)")++ keyID = fs.String("key-id", "", "accept only this Bot signing key id (profile verify)"))// Accept flags anywhere: split positionals out.var positional []string@@@ -174,6 +178,12 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) interr = cmdResume(g, stdout)case "plugins":err = cmdPlugins(g, *asJSON, stdout)++ case "profile":++ if len(positional) != 2 || positional[0] != "verify" {++ fmt.Fprintln(stderr, "usage: openvibe-node profile verify community.<name> [--server URL] [--key-id ID]")++ return 2++ }++ err = cmdProfileVerify(g, positional[1], *server, *keyID, stdout)case "version", "--version":fmt.Fprintf(stdout, "openvibe-node %s %s/%s\n", version, runtime.GOOS, runtime.GOARCH)default:@@@ -337,6 +347,7 @@ func cmdPair(g *globals, a pairArgs, stdout io.Writer) error {chownLikeDir(g.paths.CredentialFile(), g.paths.ConfigDir)fmt.Fprintf(stdout, "Paired as %s (robot %s). Credential saved to %s (mode 600).\n", creds.DeviceID, creds.RobotID, g.paths.CredentialFile())fmt.Fprintln(stdout, "Confirm the device on openvibe.bot, then start it: `openvibe-node run` or `sudo openvibe-node install`.")++ reportCommunityProfile(ctx, cfg.Server, creds.Profile, stdout)return finishPair(g, a, cfg, noConfig, stdout)}@@@ -382,6 +393,7 @@ func cmdPairNetwork(g *globals, a pairArgs, stdout io.Writer) error {}if bound.RobotID != "" {fmt.Fprintf(stdout, "Bound as %s (robot %s).\n", bound.DeviceID, bound.RobotID)++ reportCommunityProfile(ctx, creds.Server, bound.Profile, stdout)} else {fmt.Fprintf(stdout, "Bound as %s (a machine for your jobs, no robot).\n", bound.DeviceID)}@@@ -752,3 +764,42 @@ func cmdPlugins(g *globals, asJSON bool, stdout io.Writer) error {}return nil}++++// cmdProfileVerify checks the community profile id against the signature Bot made when an OpenVibe admin approved it++// (internal/profilesig), on server or the config's Bot, and says which plugin its robots run. A failed check is an error.++func cmdProfileVerify(g *globals, id, server, keyID string, stdout io.Writer) error {++ if server == "" {++ server = "https://openvibe.bot"++ if cfg, err := loadConfig(g); err == nil && cfg.Server != "" {++ server = cfg.Server++ }++ }++ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)++ defer cancel()++ res, err := profilesig.Check(ctx, nil, server, id, nil, keyID)++ if err != nil {++ return fmt.Errorf("%s: %w; do not install its driver", id, err)++ }++ fmt.Fprintf(stdout, "%s version %s: signed by %s (Bot key %s), as reviewed.\n", res.ID, res.Version, strings.TrimSuffix(server, "/"), res.KeyID)++ if res.Plugin != "" {++ fmt.Fprintf(stdout, "Its robots run the Node plugin %q: install it from the source its page links to, then list it in the config's plugins.\n", res.Plugin)++ }++ return nil++}++++// reportCommunityProfile says, after pairing, whether a community profile the robot uses is the signed one Bot++// reviewed. It never fails the pairing: the plugin is installed by hand, and this tells the owner whether to.++func reportCommunityProfile(ctx context.Context, server string, profile json.RawMessage, stdout io.Writer) {++ var p struct {++ ID string `json:"id"`++ }++ if len(profile) == 0 || json.Unmarshal(profile, &p) != nil || !profilesig.CommunityRe.MatchString(p.ID) {++ return++ }++ res, err := profilesig.Check(ctx, nil, server, p.ID, profile, "")++ if err != nil {++ fmt.Fprintf(stdout, "WARNING: the robot's community profile %s did not check out (%v). Do not install its driver until it does: openvibe-node profile verify %s\n", p.ID, err, p.ID)++ return++ }++ fmt.Fprintf(stdout, "The robot's community profile %s (version %s) is the one Bot reviewed and signed (key %s).\n", res.ID, res.Version, res.KeyID)++}nnew file mode 100644@@@ -0,0 +1,62 @@++package main++++import (++ "encoding/json"++ "net/http"++ "net/http/httptest"++ "os"++ "strings"++ "testing"++)++++// TestProfileVerify: `profile verify` checks a community profile against Bot's signature (the fixture Bot's own code++// signed) and names its plugin; another pinned key, an unsigned profile or a built-in id fail.++func TestProfileVerify(t *testing.T) {++ b, err := os.ReadFile("../../internal/profilesig/testdata/ballbot.json")++ if err != nil {++ t.Fatal(err)++ }++ var f struct {++ Served map[string]json.RawMessage `json:"served"`++ Key json.RawMessage `json:"key"`++ }++ if err := json.Unmarshal(b, &f); err != nil {++ t.Fatal(err)++ }++ signed := true++ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {++ w.Header().Set("Content-Type", "application/json")++ switch r.URL.Path {++ case "/api/v1/profiles/signing-key":++ _ = json.NewEncoder(w).Encode(map[string]any{"key": f.Key})++ case "/api/v1/profiles/community.ballbot":++ doc := map[string]any{"profile": f.Served["profile"]}++ if signed {++ doc["signature"] = f.Served["signature"]++ }++ _ = json.NewEncoder(w).Encode(doc)++ default:++ http.NotFound(w, r)++ }++ }))++ defer srv.Close()++ home := t.TempDir()++++ out, errOut, code := cli(t, "profile", "verify", "community.ballbot", "--server", srv.URL, "--home", home)++ if code != 0 || !strings.Contains(out, "as reviewed") || !strings.Contains(out, `"ballbot"`) {++ t.Fatalf("code %d\n%s\n%s", code, out, errOut)++ }++ if _, errOut, code := cli(t, "profile", "verify", "community.ballbot", "--server", srv.URL, "--key-id", "ffffffffffffffff", "--home", home); code == 0 || !strings.Contains(errOut, "do not install") {++ t.Fatalf("another pinned key: code %d %s", code, errOut)++ }++ if _, errOut, code := cli(t, "profile", "verify", "adeept.adr036", "--server", srv.URL, "--home", home); code == 0 || !strings.Contains(errOut, "not a community profile") {++ t.Fatalf("a built-in profile: code %d %s", code, errOut)++ }++ if _, _, code := cli(t, "profile", "check", "community.ballbot", "--home", home); code != 2 {++ t.Fatalf("a wrong subcommand: code %d", code)++ }++ signed = false++ if _, errOut, code := cli(t, "profile", "verify", "community.ballbot", "--server", srv.URL, "--home", home); code == 0 || !strings.Contains(errOut, "no signature") {++ t.Fatalf("unsigned: code %d %s", code, errOut)++ }++}@@@ -251,3 +251,31 @@ be one more plugin, `computer`, on the same protocol; the core does not change:- **Output larger than a line** (screenshots, file contents, terminal streams) uses the `video` pattern: the pluginwrites a file and sends its path in an `event`, and the core uploads it; the upload path is to be designed with Actor.- The local kill switch (`openvibe-node stop`) will also make the `computer` plugin drop to Observe.++++## Community plugins++++A community robot's profile (`community.<name>`, submitted on openvibe.bot and reviewed by an OpenVibe admin) names++the plugin its robots run in `mapping.plugin`. The Node never downloads a plugin: its owner installs it from the++source the profile's page links to and lists it in the config's `plugins`, as with any plugin.++++Before installing one, check that the profile is the one that was reviewed:++++```sh++openvibe-node profile verify community.ballbot # Bot from the config, else https://openvibe.bot++openvibe-node profile verify community.ballbot --key-id a7a0f02462c68119 # accept only this signing key++```++++The Node fetches the profile (`GET /api/v1/profiles/:id`, with the `signature` Bot made on approval) and Bot's key++(`GET /api/v1/profiles/signing-key`). It checks that the key's id is the first 16 hex digits of the SHA-256 of its SPKI++DER and that the signature names that key. Then it verifies Ed25519 over the profile's canonical JSON: every object's++keys sorted by UTF-16 code units, no whitespace, strings and numbers as JavaScript's `JSON.stringify` writes them++(`internal/profilesig`; its test fixture was signed by Bot's own code). The check fails, with "do not install its++driver", when:++++- the profile has no signature;++- the signature does not match;++- the key is not the pinned one.++++`pair` runs the same check when the robot uses a community profile. It also checks that the profile Bot sent the Node++is the signed one in the catalogue. A failure is a warning: pairing goes on, and the plugin stays uninstalled until++the owner installs it.nnew file mode 100644@@@ -0,0 +1,287 @@++// Package profilesig checks a community robot profile against the signature OpenVibe.Bot made when an OpenVibe admin++// approved it (Bot server/submissions.js): Ed25519 over the profile's canonical JSON — every object's keys sorted, no++// whitespace, strings and numbers exactly as JavaScript's JSON.stringify writes them — with the key Bot publishes at++// GET /api/v1/profiles/signing-key. A profile that does not check out is not the one that was reviewed: its driver++// should not be installed.++package profilesig++++import (++ "bytes"++ "context"++ "crypto/ed25519"++ "crypto/sha256"++ "crypto/x509"++ "encoding/base64"++ "encoding/hex"++ "encoding/json"++ "encoding/pem"++ "errors"++ "fmt"++ "io"++ "net/http"++ "net/url"++ "regexp"++ "sort"++ "strings"++ "time"++ "unicode/utf16"++)++++// CommunityRe is the id of a community profile (Bot's COMMUNITY_ID): only these are submitted, reviewed and signed.++var CommunityRe = regexp.MustCompile(`^community\.[a-z][a-z0-9_-]{1,40}$`)++++// Signature is the `signature` of Bot's GET /api/v1/profiles/:id.++type Signature struct {++ Alg string `json:"alg"`++ KeyID string `json:"key_id"`++ Value string `json:"value"`++}++++// Key is the `key` of Bot's GET /api/v1/profiles/signing-key.++type Key struct {++ Alg string `json:"alg"`++ KeyID string `json:"key_id"`++ PublicKeyPEM string `json:"public_key_pem"`++}++++// ErrUnsigned: Bot serves the profile without a signature (not approved, or approved before signing existed).++var ErrUnsigned = errors.New("Bot has no signature for this profile")++++// ErrBadSignature: the signature does not match the profile, so the profile changed after it was reviewed.++var ErrBadSignature = errors.New("the signature does not match the profile: it is not the profile that was reviewed")++++// Canonical returns the bytes Bot signs for the JSON value raw: keys sorted by UTF-16 code units (JavaScript's++// sort), no whitespace, numbers as written (Bot writes them with JSON.stringify), strings escaped as JSON.stringify++// escapes them.++func Canonical(raw []byte) ([]byte, error) {++ dec := json.NewDecoder(bytes.NewReader(raw))++ dec.UseNumber()++ var v any++ if err := dec.Decode(&v); err != nil {++ return nil, err++ }++ if _, err := dec.Token(); err != io.EOF {++ return nil, errors.New("more than one JSON value")++ }++ var b bytes.Buffer++ write(&b, v)++ return b.Bytes(), nil++}++++func write(b *bytes.Buffer, v any) {++ switch x := v.(type) {++ case nil:++ b.WriteString("null")++ case bool:++ if x {++ b.WriteString("true")++ } else {++ b.WriteString("false")++ }++ case json.Number:++ b.WriteString(x.String())++ case string:++ writeString(b, x)++ case []any:++ b.WriteByte('[')++ for i, e := range x {++ if i > 0 {++ b.WriteByte(',')++ }++ write(b, e)++ }++ b.WriteByte(']')++ case map[string]any:++ keys := make([]string, 0, len(x))++ for k := range x {++ keys = append(keys, k)++ }++ sort.Slice(keys, func(i, j int) bool { return lessUTF16(keys[i], keys[j]) })++ b.WriteByte('{')++ for i, k := range keys {++ if i > 0 {++ b.WriteByte(',')++ }++ writeString(b, k)++ b.WriteByte(':')++ write(b, x[k])++ }++ b.WriteByte('}')++ }++}++++// writeString writes s as JSON.stringify does: `"` and `\` escaped, \b \f \n \r \t short, other control characters++// as \u00xx, everything else (non-ASCII, U+2028 and U+2029 included) as it is.++func writeString(b *bytes.Buffer, s string) {++ b.WriteByte('"')++ for _, r := range s {++ switch r {++ case '"':++ b.WriteString(`\"`)++ case '\\':++ b.WriteString(`\\`)++ case '\b':++ b.WriteString(`\b`)++ case '\f':++ b.WriteString(`\f`)++ case '\n':++ b.WriteString(`\n`)++ case '\r':++ b.WriteString(`\r`)++ case '\t':++ b.WriteString(`\t`)++ default:++ if r < 0x20 {++ fmt.Fprintf(b, `\u%04x`, r)++ } else {++ b.WriteRune(r)++ }++ }++ }++ b.WriteByte('"')++}++++func lessUTF16(a, b string) bool {++ ua, ub := utf16.Encode([]rune(a)), utf16.Encode([]rune(b))++ for i := 0; i < len(ua) && i < len(ub); i++ {++ if ua[i] != ub[i] {++ return ua[i] < ub[i]++ }++ }++ return len(ua) < len(ub)++}++++// KeyID is Bot's id of a public key: the first 16 hex digits of the SHA-256 of its SPKI DER.++func KeyID(spkiDER []byte) string {++ sum := sha256.Sum256(spkiDER)++ return hex.EncodeToString(sum[:])[:16]++}++++// Verify checks sig over profile with key. The key's id must be the one its PEM hashes to, and the one sig names.++func Verify(profile []byte, sig Signature, key Key) error {++ if sig.Alg != "Ed25519" || key.Alg != "Ed25519" {++ return fmt.Errorf("unknown signature algorithm %q (key %q)", sig.Alg, key.Alg)++ }++ block, _ := pem.Decode([]byte(key.PublicKeyPEM))++ if block == nil {++ return errors.New("Bot's signing key is not a PEM public key")++ }++ pub, err := x509.ParsePKIXPublicKey(block.Bytes)++ if err != nil {++ return fmt.Errorf("Bot's signing key: %w", err)++ }++ edPub, ok := pub.(ed25519.PublicKey)++ if !ok {++ return errors.New("Bot's signing key is not an Ed25519 key")++ }++ if id := KeyID(block.Bytes); id != key.KeyID {++ return fmt.Errorf("Bot's signing key hashes to %s, not the %s it is published as", id, key.KeyID)++ }++ if sig.KeyID != key.KeyID {++ return fmt.Errorf("the profile was signed with key %s; Bot publishes key %s", sig.KeyID, key.KeyID)++ }++ raw, err := base64.StdEncoding.DecodeString(sig.Value)++ if err != nil {++ return fmt.Errorf("the signature is not base64: %w", err)++ }++ canon, err := Canonical(profile)++ if err != nil {++ return fmt.Errorf("the profile is not JSON: %w", err)++ }++ if !ed25519.Verify(edPub, canon, raw) {++ return ErrBadSignature++ }++ return nil++}++++// Result is what Check found out about a community profile.++type Result struct {++ ID string++ Version string++ KeyID string++ Plugin string // mapping.plugin: the Node plugin the profile's robots run, installed by the owner++}++++// Check fetches the community profile id and Bot's signing key from server (Bot's origin) and verifies the profile.++// With want (a profile Bot sent this Node, at pairing), it must also be that same profile. pinKeyID, when set, is the++// only key accepted.++func Check(ctx context.Context, client *http.Client, server, id string, want json.RawMessage, pinKeyID string) (Result, error) {++ res := Result{ID: id}++ if !CommunityRe.MatchString(id) {++ return res, fmt.Errorf("%q is not a community profile id (community.<name>): only those are reviewed and signed", id)++ }++ if client == nil {++ client = &http.Client{Timeout: 15 * time.Second}++ }++ base := strings.TrimSuffix(server, "/") + "/api/v1/profiles/"++ var doc struct {++ Profile json.RawMessage `json:"profile"`++ Signature *Signature `json:"signature"`++ }++ if err := getJSON(ctx, client, base+url.PathEscape(id), &doc); err != nil {++ return res, err++ }++ var meta struct {++ ID string `json:"id"`++ Version any `json:"version"`++ Mapping map[string]any `json:"mapping"`++ }++ _ = json.Unmarshal(doc.Profile, &meta)++ if meta.Version != nil {++ res.Version = fmt.Sprint(meta.Version)++ }++ if p, ok := meta.Mapping["plugin"].(string); ok {++ res.Plugin = p++ }++ if meta.ID != id {++ return res, fmt.Errorf("Bot answered with profile %q for %q", meta.ID, id)++ }++ if doc.Signature == nil {++ return res, ErrUnsigned++ }++ var keyDoc struct {++ Key Key `json:"key"`++ }++ if err := getJSON(ctx, client, base+"signing-key", &keyDoc); err != nil {++ return res, fmt.Errorf("Bot's signing key: %w", err)++ }++ res.KeyID = keyDoc.Key.KeyID++ if pinKeyID != "" && keyDoc.Key.KeyID != pinKeyID {++ return res, fmt.Errorf("Bot signs with key %s, not the pinned %s", keyDoc.Key.KeyID, pinKeyID)++ }++ if err := Verify(doc.Profile, *doc.Signature, keyDoc.Key); err != nil {++ return res, err++ }++ if len(want) > 0 {++ a, errA := Canonical(want)++ b, errB := Canonical(doc.Profile)++ if errA != nil || errB != nil || !bytes.Equal(a, b) {++ return res, errors.New("the profile Bot sent this Node is not the signed one in its catalogue (another version, or changed)")++ }++ }++ return res, nil++}++++func getJSON(ctx context.Context, client *http.Client, u string, into any) error {++ req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)++ if err != nil {++ return err++ }++ req.Header.Set("Accept", "application/json")++ resp, err := client.Do(req)++ if err != nil {++ return err++ }++ defer resp.Body.Close()++ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))++ if err != nil {++ return err++ }++ if resp.StatusCode == http.StatusNotFound {++ return fmt.Errorf("%s: not found", u)++ }++ if resp.StatusCode != http.StatusOK {++ return fmt.Errorf("%s: HTTP %d", u, resp.StatusCode)++ }++ return json.Unmarshal(body, into)++}nnew file mode 100644@@@ -0,0 +1,137 @@++package profilesig++++import (++ "context"++ "crypto/ed25519"++ "crypto/rand"++ "crypto/x509"++ "encoding/json"++ "encoding/pem"++ "errors"++ "net/http"++ "net/http/httptest"++ "os"++ "strings"++ "testing"++)++++// testdata/ballbot.json was made by OpenVibe.Bot's own code (server/submissions.js canonicalJson and loadKey, a fresh++// Ed25519 key): a community profile with keys out of order, escapes, non-ASCII, U+2028, a control character and++// numbers JavaScript writes in exponent form, its signature and Bot's public key, and the canonical bytes Bot signed.++type fixture struct {++ Served struct {++ Profile json.RawMessage `json:"profile"`++ Signature Signature `json:"signature"`++ } `json:"served"`++ Key Key `json:"key"`++ Canonical string `json:"canonical"`++}++++func load(t *testing.T) fixture {++ t.Helper()++ b, err := os.ReadFile("testdata/ballbot.json")++ if err != nil {++ t.Fatal(err)++ }++ var f fixture++ if err := json.Unmarshal(b, &f); err != nil {++ t.Fatal(err)++ }++ return f++}++++func TestCanonicalIsBots(t *testing.T) {++ f := load(t)++ got, err := Canonical(f.Served.Profile)++ if err != nil {++ t.Fatal(err)++ }++ if string(got) != f.Canonical {++ t.Fatalf("canonical JSON differs from Bot's:\n got %s\nwant %s", got, f.Canonical)++ }++ if _, err := Canonical([]byte(`{"a":1} {"b":2}`)); err == nil {++ t.Fatal("two values were accepted")++ }++}++++func TestVerify(t *testing.T) {++ f := load(t)++ if err := Verify(f.Served.Profile, f.Served.Signature, f.Key); err != nil {++ t.Fatalf("Bot's signature: %v", err)++ }++ changed := []byte(strings.Replace(string(f.Served.Profile), `"max_speed": 0.5`, `"max_speed": 5`, 1))++ if string(changed) == string(f.Served.Profile) {++ t.Fatal("the fixture has no max_speed to change")++ }++ if err := Verify(changed, f.Served.Signature, f.Key); !errors.Is(err, ErrBadSignature) {++ t.Fatalf("a changed profile: %v", err)++ }++ other := f.Served.Signature++ other.KeyID = "0000000000000000"++ if err := Verify(f.Served.Profile, other, f.Key); err == nil || !strings.Contains(err.Error(), "signed with key") {++ t.Fatalf("a signature by another key: %v", err)++ }++ pub, _, _ := ed25519.GenerateKey(rand.Reader)++ der, _ := x509.MarshalPKIXPublicKey(pub)++ swapped := f.Key++ swapped.PublicKeyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der}))++ if err := Verify(f.Served.Profile, f.Served.Signature, swapped); err == nil || !strings.Contains(err.Error(), "hashes to") {++ t.Fatalf("a key that is not the published id: %v", err)++ }++ swapped.KeyID = KeyID(der)++ sig := f.Served.Signature++ sig.KeyID = swapped.KeyID++ if err := Verify(f.Served.Profile, sig, swapped); !errors.Is(err, ErrBadSignature) {++ t.Fatalf("another key under its own id: %v", err)++ }++}++++func TestCheck(t *testing.T) {++ f := load(t)++ signed := true++ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {++ w.Header().Set("Content-Type", "application/json")++ switch r.URL.Path {++ case "/api/v1/profiles/signing-key":++ _ = json.NewEncoder(w).Encode(map[string]any{"key": f.Key})++ case "/api/v1/profiles/community.ballbot":++ doc := map[string]any{"profile": f.Served.Profile}++ if signed {++ doc["signature"] = f.Served.Signature++ }++ _ = json.NewEncoder(w).Encode(doc)++ default:++ http.NotFound(w, r)++ }++ }))++ defer srv.Close()++ ctx := context.Background()++++ res, err := Check(ctx, srv.Client(), srv.URL, "community.ballbot", nil, "")++ if err != nil {++ t.Fatal(err)++ }++ if res.Plugin != "ballbot" || res.Version != "1" || res.KeyID != f.Key.KeyID {++ t.Fatalf("%+v", res)++ }++ if _, err := Check(ctx, srv.Client(), srv.URL, "community.ballbot", f.Served.Profile, f.Key.KeyID); err != nil {++ t.Fatalf("the same profile, the pinned key: %v", err)++ }++ if _, err := Check(ctx, srv.Client(), srv.URL, "community.ballbot", nil, "ffffffffffffffff"); err == nil || !strings.Contains(err.Error(), "pinned") {++ t.Fatalf("another pinned key: %v", err)++ }++ sent := json.RawMessage(strings.Replace(string(f.Served.Profile), `"max_turn": 0.6`, `"max_turn": 1`, 1))++ if _, err := Check(ctx, srv.Client(), srv.URL, "community.ballbot", sent, ""); err == nil || !strings.Contains(err.Error(), "not the signed one") {++ t.Fatalf("a profile sent at pairing that is not the catalogue's: %v", err)++ }++ if _, err := Check(ctx, srv.Client(), srv.URL, "community.nope", nil, ""); err == nil || !strings.Contains(err.Error(), "not found") {++ t.Fatalf("an unknown profile: %v", err)++ }++ if _, err := Check(ctx, srv.Client(), srv.URL, "adeept.adr036", nil, ""); err == nil || !strings.Contains(err.Error(), "not a community profile") {++ t.Fatalf("a built-in profile: %v", err)++ }++ signed = false++ if _, err := Check(ctx, srv.Client(), srv.URL, "community.ballbot", nil, ""); !errors.Is(err, ErrUnsigned) {++ t.Fatalf("no signature: %v", err)++ }++}nnew file mode 100644@@@ -0,0 +1,68 @@++{++ "served": {++ "profile": {++ "id": "community.ballbot",++ "version": 1,++ "name": "Ballbot \"Mk\\2\" é ✓ \u0007 tab\there",++ "vendor": "Mia",++ "kind": "onboard",++ "description": "A self-balancing ball robot on an ESP32, driven by a community Node plugin. <b>&</b>",++ "capabilities": [++ "drive.differential",++ "battery"++ ],++ "mapping": {++ "plugin": "ballbot",++ "driver": "plugin"++ },++ "commands": {++ "drive": {++ "axes": {++ "throttle": [++ -1,++ 1++ ],++ "steer": [++ -1.5,++ 1e+21,++ 0.1,++ 1e-7++ ]++ }++ }++ },++ "widgets": [++ {++ "type": "drive",++ "label": "Drive",++ "capability": "drive.differential",++ "order": 1,++ "command": {++ "kind": "drive"++ }++ }++ ],++ "limits": {++ "max_turn": 0.6,++ "max_speed": 0.5,++ "max_command_ms": 300,++ "heartbeat_ms": 1000,++ "Zeta": null,++ "alpha": true,++ "é": 1,++ "Z": 2++ }++ },++ "signature": {++ "alg": "Ed25519",++ "key_id": "a7a0f02462c68119",++ "value": "rnwR4vMilhtNtDPTNmpm2W3zPPfduwUVDPLry9WJyarnipsCJit/DlEkekDK3ZQqRnP5v8Yo5AMpjcWKTbufAQ=="++ }++ },++ "key": {++ "alg": "Ed25519",++ "key_id": "a7a0f02462c68119",++ "public_key_pem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA8NQGtoP2IvafI+EuePwk1L+bAf1ahWx65v474RSPoxM=\n-----END PUBLIC KEY-----\n"++ },++ "canonical": "{\"capabilities\":[\"drive.differential\",\"battery\"],\"commands\":{\"drive\":{\"axes\":{\"steer\":[-1.5,1e+21,0.1,1e-7],\"throttle\":[-1,1]}}},\"description\":\"A self-balancing ball robot on an ESP32, driven by a community Node plugin. <b>&</b>\",\"id\":\"community.ballbot\",\"kind\":\"onboard\",\"limits\":{\"Z\":2,\"Zeta\":null,\"alpha\":true,\"heartbeat_ms\":1000,\"max_command_ms\":300,\"max_speed\":0.5,\"max_turn\":0.6,\"é\":1},\"mapping\":{\"driver\":\"plugin\",\"plugin\":\"ballbot\"},\"name\":\"Ballbot \\\"Mk\\\\2\\\" é ✓ \\u0007 tab\\there\",\"vendor\":\"Mia\",\"version\":1,\"widgets\":[{\"capability\":\"drive.differential\",\"command\":{\"kind\":\"drive\"},\"label\":\"Drive\",\"order\":1,\"type\":\"drive\"}]}"++}
@@ -34,6 +34,12 @@ openvibe-node resume
@@ -34,6 +34,12 @@ openvibe-node resume
- **ESP32 library** ([`esp32/`](esp32/README.md), `OpenVibeNode`): the device side of the protocol for ESP32 boards —
- **ESP32 library** ([`esp32/`](esp32/README.md), `OpenVibeNode`): the device side of the protocol for ESP32 boards —
pairing, the control link, drive/actuator commands, telemetry, heartbeat, deadman and e-stop — but no jobs or video.
pairing, the control link, drive/actuator commands, telemetry, heartbeat, deadman and e-stop — but no jobs or video.
+**Community robots:** a profile people submit on openvibe.bot (`community.<name>`) is reviewed by an OpenVibe admin
+and signed by Bot (Ed25519 over its canonical JSON). `openvibe-node profile verify community.<name>` checks that
+signature against the key Bot publishes (`--key-id` pins one) and names the plugin the robots run; pairing with a robot
+on a community profile runs the same check and warns when it fails. Install a community plugin only when it passes
+([docs/plugins.md](docs/plugins.md#community-plugins)).
+
**A machine that runs your jobs:** add it on openvibe.bot/machines, which prints the same installer with `--worker`: the
**A machine that runs your jobs:** add it on openvibe.bot/machines, which prints the same installer with `--worker`: the
job worker runs each job as an unprivileged user in a sandbox, and the OpenVibe.Codes job runner lets you start a coding
job worker runs each job as an unprivileged user in a sandbox, and the OpenVibe.Codes job runner lets you start a coding
task on openvibe.codes/run from any browser and have this machine do it ([docs/worker.md](docs/worker.md)).
task on openvibe.codes/run from any browser and have this machine do it ([docs/worker.md](docs/worker.md)).
@@ -34,6 +34,7 @@ import (
@@ -34,6 +34,7 @@ import (
"github.com/OpenVibers/OpenVibe.Node/internal/localctl"
"github.com/OpenVibers/OpenVibe.Node/internal/localctl"
"github.com/OpenVibers/OpenVibe.Node/internal/node"
"github.com/OpenVibers/OpenVibe.Node/internal/node"
"github.com/OpenVibers/OpenVibe.Node/internal/plugins"
"github.com/OpenVibers/OpenVibe.Node/internal/plugins"
+ "github.com/OpenVibers/OpenVibe.Node/internal/profilesig"
"github.com/OpenVibers/OpenVibe.Node/internal/protocol"
"github.com/OpenVibers/OpenVibe.Node/internal/protocol"
"github.com/OpenVibers/OpenVibe.Node/internal/safety"
"github.com/OpenVibers/OpenVibe.Node/internal/safety"
"github.com/OpenVibers/OpenVibe.Node/internal/service"
"github.com/OpenVibers/OpenVibe.Node/internal/service"
@@ -56,6 +57,8 @@ Usage:
@@ -56,6 +57,8 @@ Usage:
openvibe-node stop stop every actuator now and hold them stopped
openvibe-node stop stop every actuator now and hold them stopped
openvibe-node resume release the stop
openvibe-node resume release the stop
openvibe-node plugins [--json]
openvibe-node plugins [--json]
+ openvibe-node profile verify community.<name> [--server URL] [--key-id ID]
+ check a community robot profile against the signature Bot made when it was reviewed
openvibe-node version
openvibe-node version
Global flags (before or after the command):
Global flags (before or after the command):
@@ -103,6 +106,7 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
@@ -103,6 +106,7 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
asJSON = fs.Bool("json", false, "print JSON (status, plugins)")
asJSON = fs.Bool("json", false, "print JSON (status, plugins)")
svcUser = fs.String("user", "", "run the service as this account (install)")
svcUser = fs.String("user", "", "run the service as this account (install)")
session = fs.Bool("user-session", false, "Windows: start at sign-in in your own session instead of as a service (install, uninstall)")
session = fs.Bool("user-session", false, "Windows: start at sign-in in your own session instead of as a service (install, uninstall)")
+ keyID = fs.String("key-id", "", "accept only this Bot signing key id (profile verify)")
)
)
// Accept flags anywhere: split positionals out.
// Accept flags anywhere: split positionals out.
var positional []string
var positional []string
@@ -174,6 +178,12 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
@@ -174,6 +178,12 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
err = cmdResume(g, stdout)
err = cmdResume(g, stdout)
case "plugins":
case "plugins":
err = cmdPlugins(g, *asJSON, stdout)
err = cmdPlugins(g, *asJSON, stdout)
+ case "profile":
+ if len(positional) != 2 || positional[0] != "verify" {
+ fmt.Fprintln(stderr, "usage: openvibe-node profile verify community.<name> [--server URL] [--key-id ID]")
+ return 2
+ }
+ err = cmdProfileVerify(g, positional[1], *server, *keyID, stdout)
case "version", "--version":
case "version", "--version":
fmt.Fprintf(stdout, "openvibe-node %s %s/%s\n", version, runtime.GOOS, runtime.GOARCH)
fmt.Fprintf(stdout, "openvibe-node %s %s/%s\n", version, runtime.GOOS, runtime.GOARCH)
default:
default:
@@ -337,6 +347,7 @@ func cmdPair(g *globals, a pairArgs, stdout io.Writer) error {
@@ -337,6 +347,7 @@ func cmdPair(g *globals, a pairArgs, stdout io.Writer) error {
chownLikeDir(g.paths.CredentialFile(), g.paths.ConfigDir)
chownLikeDir(g.paths.CredentialFile(), g.paths.ConfigDir)
fmt.Fprintf(stdout, "Paired as %s (robot %s). Credential saved to %s (mode 600).\n", creds.DeviceID, creds.RobotID, g.paths.CredentialFile())
fmt.Fprintf(stdout, "Paired as %s (robot %s). Credential saved to %s (mode 600).\n", creds.DeviceID, creds.RobotID, g.paths.CredentialFile())
fmt.Fprintln(stdout, "Confirm the device on openvibe.bot, then start it: `openvibe-node run` or `sudo openvibe-node install`.")
fmt.Fprintln(stdout, "Confirm the device on openvibe.bot, then start it: `openvibe-node run` or `sudo openvibe-node install`.")
+ reportCommunityProfile(ctx, cfg.Server, creds.Profile, stdout)
return finishPair(g, a, cfg, noConfig, stdout)
return finishPair(g, a, cfg, noConfig, stdout)
}
}
@@ -382,6 +393,7 @@ func cmdPairNetwork(g *globals, a pairArgs, stdout io.Writer) error {
@@ -382,6 +393,7 @@ func cmdPairNetwork(g *globals, a pairArgs, stdout io.Writer) error {
}
}
if bound.RobotID != "" {
if bound.RobotID != "" {
fmt.Fprintf(stdout, "Bound as %s (robot %s).\n", bound.DeviceID, bound.RobotID)
fmt.Fprintf(stdout, "Bound as %s (robot %s).\n", bound.DeviceID, bound.RobotID)
+ reportCommunityProfile(ctx, creds.Server, bound.Profile, stdout)
} else {
} else {
fmt.Fprintf(stdout, "Bound as %s (a machine for your jobs, no robot).\n", bound.DeviceID)
fmt.Fprintf(stdout, "Bound as %s (a machine for your jobs, no robot).\n", bound.DeviceID)
}
}
@@ -752,3 +764,42 @@ func cmdPlugins(g *globals, asJSON bool, stdout io.Writer) error {
@@ -752,3 +764,42 @@ func cmdPlugins(g *globals, asJSON bool, stdout io.Writer) error {
}
}
return nil
return nil
}
}
+
+// cmdProfileVerify checks the community profile id against the signature Bot made when an OpenVibe admin approved it
+// (internal/profilesig), on server or the config's Bot, and says which plugin its robots run. A failed check is an error.
+func cmdProfileVerify(g *globals, id, server, keyID string, stdout io.Writer) error {
+ if server == "" {
+ server = "https://openvibe.bot"
+ if cfg, err := loadConfig(g); err == nil && cfg.Server != "" {
+ server = cfg.Server
+ }
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ res, err := profilesig.Check(ctx, nil, server, id, nil, keyID)
+ if err != nil {
+ return fmt.Errorf("%s: %w; do not install its driver", id, err)
+ }
+ fmt.Fprintf(stdout, "%s version %s: signed by %s (Bot key %s), as reviewed.\n", res.ID, res.Version, strings.TrimSuffix(server, "/"), res.KeyID)
+ if res.Plugin != "" {
+ fmt.Fprintf(stdout, "Its robots run the Node plugin %q: install it from the source its page links to, then list it in the config's plugins.\n", res.Plugin)
+ }
+ return nil
+}
+
+// reportCommunityProfile says, after pairing, whether a community profile the robot uses is the signed one Bot
+// reviewed. It never fails the pairing: the plugin is installed by hand, and this tells the owner whether to.
+func reportCommunityProfile(ctx context.Context, server string, profile json.RawMessage, stdout io.Writer) {
+ var p struct {
+ ID string `json:"id"`
+ }
+ if len(profile) == 0 || json.Unmarshal(profile, &p) != nil || !profilesig.CommunityRe.MatchString(p.ID) {
+ return
+ }
+ res, err := profilesig.Check(ctx, nil, server, p.ID, profile, "")
+ if err != nil {
+ fmt.Fprintf(stdout, "WARNING: the robot's community profile %s did not check out (%v). Do not install its driver until it does: openvibe-node profile verify %s\n", p.ID, err, p.ID)
+ return
+ }
+ fmt.Fprintf(stdout, "The robot's community profile %s (version %s) is the one Bot reviewed and signed (key %s).\n", res.ID, res.Version, res.KeyID)
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -0,0 +1,62 @@
+package main
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "testing"
+)
+
+// TestProfileVerify: `profile verify` checks a community profile against Bot's signature (the fixture Bot's own code
+// signed) and names its plugin; another pinned key, an unsigned profile or a built-in id fail.
+func TestProfileVerify(t *testing.T) {
+ b, err := os.ReadFile("../../internal/profilesig/testdata/ballbot.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var f struct {
+ Served map[string]json.RawMessage `json:"served"`
+ Key json.RawMessage `json:"key"`
+ }
+ if err := json.Unmarshal(b, &f); err != nil {
+ t.Fatal(err)
+ }
+ signed := true
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Path {
+ case "/api/v1/profiles/signing-key":
+ _ = json.NewEncoder(w).Encode(map[string]any{"key": f.Key})
+ case "/api/v1/profiles/community.ballbot":
+ doc := map[string]any{"profile": f.Served["profile"]}
+ if signed {
+ doc["signature"] = f.Served["signature"]
+ }
+ _ = json.NewEncoder(w).Encode(doc)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer srv.Close()
+ home := t.TempDir()
+
+ out, errOut, code := cli(t, "profile", "verify", "community.ballbot", "--server", srv.URL, "--home", home)
+ if code != 0 || !strings.Contains(out, "as reviewed") || !strings.Contains(out, `"ballbot"`) {
+ t.Fatalf("code %d\n%s\n%s", code, out, errOut)
+ }
+ if _, errOut, code := cli(t, "profile", "verify", "community.ballbot", "--server", srv.URL, "--key-id", "ffffffffffffffff", "--home", home); code == 0 || !strings.Contains(errOut, "do not install") {
+ t.Fatalf("another pinned key: code %d %s", code, errOut)
+ }
+ if _, errOut, code := cli(t, "profile", "verify", "adeept.adr036", "--server", srv.URL, "--home", home); code == 0 || !strings.Contains(errOut, "not a community profile") {
+ t.Fatalf("a built-in profile: code %d %s", code, errOut)
+ }
+ if _, _, code := cli(t, "profile", "check", "community.ballbot", "--home", home); code != 2 {
+ t.Fatalf("a wrong subcommand: code %d", code)
+ }
+ signed = false
+ if _, errOut, code := cli(t, "profile", "verify", "community.ballbot", "--server", srv.URL, "--home", home); code == 0 || !strings.Contains(errOut, "no signature") {
+ t.Fatalf("unsigned: code %d %s", code, errOut)
+ }
+}
@@ -251,3 +251,31 @@ be one more plugin, `computer`, on the same protocol; the core does not change:
@@ -251,3 +251,31 @@ be one more plugin, `computer`, on the same protocol; the core does not change:
- **Output larger than a line** (screenshots, file contents, terminal streams) uses the `video` pattern: the plugin
- **Output larger than a line** (screenshots, file contents, terminal streams) uses the `video` pattern: the plugin
writes a file and sends its path in an `event`, and the core uploads it; the upload path is to be designed with Actor.
writes a file and sends its path in an `event`, and the core uploads it; the upload path is to be designed with Actor.
- The local kill switch (`openvibe-node stop`) will also make the `computer` plugin drop to Observe.
- The local kill switch (`openvibe-node stop`) will also make the `computer` plugin drop to Observe.
+
+## Community plugins
+
+A community robot's profile (`community.<name>`, submitted on openvibe.bot and reviewed by an OpenVibe admin) names
+the plugin its robots run in `mapping.plugin`. The Node never downloads a plugin: its owner installs it from the
+source the profile's page links to and lists it in the config's `plugins`, as with any plugin.
+
+Before installing one, check that the profile is the one that was reviewed:
+
+```sh
+openvibe-node profile verify community.ballbot # Bot from the config, else https://openvibe.bot
+openvibe-node profile verify community.ballbot --key-id a7a0f02462c68119 # accept only this signing key
+```
+
+The Node fetches the profile (`GET /api/v1/profiles/:id`, with the `signature` Bot made on approval) and Bot's key
+(`GET /api/v1/profiles/signing-key`). It checks that the key's id is the first 16 hex digits of the SHA-256 of its SPKI
+DER and that the signature names that key. Then it verifies Ed25519 over the profile's canonical JSON: every object's
+keys sorted by UTF-16 code units, no whitespace, strings and numbers as JavaScript's `JSON.stringify` writes them
+(`internal/profilesig`; its test fixture was signed by Bot's own code). The check fails, with "do not install its
+driver", when:
+
+- the profile has no signature;
+- the signature does not match;
+- the key is not the pinned one.
+
+`pair` runs the same check when the robot uses a community profile. It also checks that the profile Bot sent the Node
+is the signed one in the catalogue. A failure is a warning: pairing goes on, and the plugin stays uninstalled until
+the owner installs it.
new file mode 100644
new file mode 100644
@@ -0,0 +1,287 @@
@@ -0,0 +1,287 @@
+// Package profilesig checks a community robot profile against the signature OpenVibe.Bot made when an OpenVibe admin
+// approved it (Bot server/submissions.js): Ed25519 over the profile's canonical JSON — every object's keys sorted, no
+// whitespace, strings and numbers exactly as JavaScript's JSON.stringify writes them — with the key Bot publishes at
+// GET /api/v1/profiles/signing-key. A profile that does not check out is not the one that was reviewed: its driver
+// should not be installed.
+package profilesig
+
+import (
+ "bytes"
+ "context"
+ "crypto/ed25519"
+ "crypto/sha256"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "encoding/pem"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "regexp"
+ "sort"
+ "strings"
+ "time"
+ "unicode/utf16"
+)
+
+// CommunityRe is the id of a community profile (Bot's COMMUNITY_ID): only these are submitted, reviewed and signed.
+var CommunityRe = regexp.MustCompile(`^community\.[a-z][a-z0-9_-]{1,40}$`)
+
+// Signature is the `signature` of Bot's GET /api/v1/profiles/:id.
+type Signature struct {
+ Alg string `json:"alg"`
+ KeyID string `json:"key_id"`
+ Value string `json:"value"`
+}
+
+// Key is the `key` of Bot's GET /api/v1/profiles/signing-key.
+type Key struct {
+ Alg string `json:"alg"`
+ KeyID string `json:"key_id"`
+ PublicKeyPEM string `json:"public_key_pem"`
+}
+
+// ErrUnsigned: Bot serves the profile without a signature (not approved, or approved before signing existed).
+var ErrUnsigned = errors.New("Bot has no signature for this profile")
+
+// ErrBadSignature: the signature does not match the profile, so the profile changed after it was reviewed.
+var ErrBadSignature = errors.New("the signature does not match the profile: it is not the profile that was reviewed")
+
+// Canonical returns the bytes Bot signs for the JSON value raw: keys sorted by UTF-16 code units (JavaScript's
+// sort), no whitespace, numbers as written (Bot writes them with JSON.stringify), strings escaped as JSON.stringify
+// escapes them.
+func Canonical(raw []byte) ([]byte, error) {
+ dec := json.NewDecoder(bytes.NewReader(raw))
+ dec.UseNumber()
+ var v any
+ if err := dec.Decode(&v); err != nil {
+ return nil, err
+ }
+ if _, err := dec.Token(); err != io.EOF {
+ return nil, errors.New("more than one JSON value")
+ }
+ var b bytes.Buffer
+ write(&b, v)
+ return b.Bytes(), nil
+}
+
+func write(b *bytes.Buffer, v any) {
+ switch x := v.(type) {
+ case nil:
+ b.WriteString("null")
+ case bool:
+ if x {
+ b.WriteString("true")
+ } else {
+ b.WriteString("false")
+ }
+ case json.Number:
+ b.WriteString(x.String())
+ case string:
+ writeString(b, x)
+ case []any:
+ b.WriteByte('[')
+ for i, e := range x {
+ if i > 0 {
+ b.WriteByte(',')
+ }
+ write(b, e)
+ }
+ b.WriteByte(']')
+ case map[string]any:
+ keys := make([]string, 0, len(x))
+ for k := range x {
+ keys = append(keys, k)
+ }
+ sort.Slice(keys, func(i, j int) bool { return lessUTF16(keys[i], keys[j]) })
+ b.WriteByte('{')
+ for i, k := range keys {
+ if i > 0 {
+ b.WriteByte(',')
+ }
+ writeString(b, k)
+ b.WriteByte(':')
+ write(b, x[k])
+ }
+ b.WriteByte('}')
+ }
+}
+
+// writeString writes s as JSON.stringify does: `"` and `\` escaped, \b \f \n \r \t short, other control characters
+// as \u00xx, everything else (non-ASCII, U+2028 and U+2029 included) as it is.
+func writeString(b *bytes.Buffer, s string) {
+ b.WriteByte('"')
+ for _, r := range s {
+ switch r {
+ case '"':
+ b.WriteString(`\"`)
+ case '\\':
+ b.WriteString(`\\`)
+ case '\b':
+ b.WriteString(`\b`)
+ case '\f':
+ b.WriteString(`\f`)
+ case '\n':
+ b.WriteString(`\n`)
+ case '\r':
+ b.WriteString(`\r`)
+ case '\t':
+ b.WriteString(`\t`)
+ default:
+ if r < 0x20 {
+ fmt.Fprintf(b, `\u%04x`, r)
+ } else {
+ b.WriteRune(r)
+ }
+ }
+ }
+ b.WriteByte('"')
+}
+
+func lessUTF16(a, b string) bool {
+ ua, ub := utf16.Encode([]rune(a)), utf16.Encode([]rune(b))
+ for i := 0; i < len(ua) && i < len(ub); i++ {
+ if ua[i] != ub[i] {
+ return ua[i] < ub[i]
+ }
+ }
+ return len(ua) < len(ub)
+}
+
+// KeyID is Bot's id of a public key: the first 16 hex digits of the SHA-256 of its SPKI DER.
+func KeyID(spkiDER []byte) string {
+ sum := sha256.Sum256(spkiDER)
+ return hex.EncodeToString(sum[:])[:16]
+}
+
+// Verify checks sig over profile with key. The key's id must be the one its PEM hashes to, and the one sig names.
+func Verify(profile []byte, sig Signature, key Key) error {
+ if sig.Alg != "Ed25519" || key.Alg != "Ed25519" {
+ return fmt.Errorf("unknown signature algorithm %q (key %q)", sig.Alg, key.Alg)
+ }
+ block, _ := pem.Decode([]byte(key.PublicKeyPEM))
+ if block == nil {
+ return errors.New("Bot's signing key is not a PEM public key")
+ }
+ pub, err := x509.ParsePKIXPublicKey(block.Bytes)
+ if err != nil {
+ return fmt.Errorf("Bot's signing key: %w", err)
+ }
+ edPub, ok := pub.(ed25519.PublicKey)
+ if !ok {
+ return errors.New("Bot's signing key is not an Ed25519 key")
+ }
+ if id := KeyID(block.Bytes); id != key.KeyID {
+ return fmt.Errorf("Bot's signing key hashes to %s, not the %s it is published as", id, key.KeyID)
+ }
+ if sig.KeyID != key.KeyID {
+ return fmt.Errorf("the profile was signed with key %s; Bot publishes key %s", sig.KeyID, key.KeyID)
+ }
+ raw, err := base64.StdEncoding.DecodeString(sig.Value)
+ if err != nil {
+ return fmt.Errorf("the signature is not base64: %w", err)
+ }
+ canon, err := Canonical(profile)
+ if err != nil {
+ return fmt.Errorf("the profile is not JSON: %w", err)
+ }
+ if !ed25519.Verify(edPub, canon, raw) {
+ return ErrBadSignature
+ }
+ return nil
+}
+
+// Result is what Check found out about a community profile.
+type Result struct {
+ ID string
+ Version string
+ KeyID string
+ Plugin string // mapping.plugin: the Node plugin the profile's robots run, installed by the owner
+}
+
+// Check fetches the community profile id and Bot's signing key from server (Bot's origin) and verifies the profile.
+// With want (a profile Bot sent this Node, at pairing), it must also be that same profile. pinKeyID, when set, is the
+// only key accepted.
+func Check(ctx context.Context, client *http.Client, server, id string, want json.RawMessage, pinKeyID string) (Result, error) {
+ res := Result{ID: id}
+ if !CommunityRe.MatchString(id) {
+ return res, fmt.Errorf("%q is not a community profile id (community.<name>): only those are reviewed and signed", id)
+ }
+ if client == nil {
+ client = &http.Client{Timeout: 15 * time.Second}
+ }
+ base := strings.TrimSuffix(server, "/") + "/api/v1/profiles/"
+ var doc struct {
+ Profile json.RawMessage `json:"profile"`
+ Signature *Signature `json:"signature"`
+ }
+ if err := getJSON(ctx, client, base+url.PathEscape(id), &doc); err != nil {
+ return res, err
+ }
+ var meta struct {
+ ID string `json:"id"`
+ Version any `json:"version"`
+ Mapping map[string]any `json:"mapping"`
+ }
+ _ = json.Unmarshal(doc.Profile, &meta)
+ if meta.Version != nil {
+ res.Version = fmt.Sprint(meta.Version)
+ }
+ if p, ok := meta.Mapping["plugin"].(string); ok {
+ res.Plugin = p
+ }
+ if meta.ID != id {
+ return res, fmt.Errorf("Bot answered with profile %q for %q", meta.ID, id)
+ }
+ if doc.Signature == nil {
+ return res, ErrUnsigned
+ }
+ var keyDoc struct {
+ Key Key `json:"key"`
+ }
+ if err := getJSON(ctx, client, base+"signing-key", &keyDoc); err != nil {
+ return res, fmt.Errorf("Bot's signing key: %w", err)
+ }
+ res.KeyID = keyDoc.Key.KeyID
+ if pinKeyID != "" && keyDoc.Key.KeyID != pinKeyID {
+ return res, fmt.Errorf("Bot signs with key %s, not the pinned %s", keyDoc.Key.KeyID, pinKeyID)
+ }
+ if err := Verify(doc.Profile, *doc.Signature, keyDoc.Key); err != nil {
+ return res, err
+ }
+ if len(want) > 0 {
+ a, errA := Canonical(want)
+ b, errB := Canonical(doc.Profile)
+ if errA != nil || errB != nil || !bytes.Equal(a, b) {
+ return res, errors.New("the profile Bot sent this Node is not the signed one in its catalogue (another version, or changed)")
+ }
+ }
+ return res, nil
+}
+
+func getJSON(ctx context.Context, client *http.Client, u string, into any) error {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Accept", "application/json")
+ resp, err := client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if err != nil {
+ return err
+ }
+ if resp.StatusCode == http.StatusNotFound {
+ return fmt.Errorf("%s: not found", u)
+ }
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("%s: HTTP %d", u, resp.StatusCode)
+ }
+ return json.Unmarshal(body, into)
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,137 @@
@@ -0,0 +1,137 @@
+package profilesig
+
+import (
+ "context"
+ "crypto/ed25519"
+ "crypto/rand"
+ "crypto/x509"
+ "encoding/json"
+ "encoding/pem"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "testing"
+)
+
+// testdata/ballbot.json was made by OpenVibe.Bot's own code (server/submissions.js canonicalJson and loadKey, a fresh
+// Ed25519 key): a community profile with keys out of order, escapes, non-ASCII, U+2028, a control character and
+// numbers JavaScript writes in exponent form, its signature and Bot's public key, and the canonical bytes Bot signed.
+type fixture struct {
+ Served struct {
+ Profile json.RawMessage `json:"profile"`
+ Signature Signature `json:"signature"`
+ } `json:"served"`
+ Key Key `json:"key"`
+ Canonical string `json:"canonical"`
+}
+
+func load(t *testing.T) fixture {
+ t.Helper()
+ b, err := os.ReadFile("testdata/ballbot.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var f fixture
+ if err := json.Unmarshal(b, &f); err != nil {
+ t.Fatal(err)
+ }
+ return f
+}
+
+func TestCanonicalIsBots(t *testing.T) {
+ f := load(t)
+ got, err := Canonical(f.Served.Profile)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(got) != f.Canonical {
+ t.Fatalf("canonical JSON differs from Bot's:\n got %s\nwant %s", got, f.Canonical)
+ }
+ if _, err := Canonical([]byte(`{"a":1} {"b":2}`)); err == nil {
+ t.Fatal("two values were accepted")
+ }
+}
+
+func TestVerify(t *testing.T) {
+ f := load(t)
+ if err := Verify(f.Served.Profile, f.Served.Signature, f.Key); err != nil {
+ t.Fatalf("Bot's signature: %v", err)
+ }
+ changed := []byte(strings.Replace(string(f.Served.Profile), `"max_speed": 0.5`, `"max_speed": 5`, 1))
+ if string(changed) == string(f.Served.Profile) {
+ t.Fatal("the fixture has no max_speed to change")
+ }
+ if err := Verify(changed, f.Served.Signature, f.Key); !errors.Is(err, ErrBadSignature) {
+ t.Fatalf("a changed profile: %v", err)
+ }
+ other := f.Served.Signature
+ other.KeyID = "0000000000000000"
+ if err := Verify(f.Served.Profile, other, f.Key); err == nil || !strings.Contains(err.Error(), "signed with key") {
+ t.Fatalf("a signature by another key: %v", err)
+ }
+ pub, _, _ := ed25519.GenerateKey(rand.Reader)
+ der, _ := x509.MarshalPKIXPublicKey(pub)
+ swapped := f.Key
+ swapped.PublicKeyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der}))
+ if err := Verify(f.Served.Profile, f.Served.Signature, swapped); err == nil || !strings.Contains(err.Error(), "hashes to") {
+ t.Fatalf("a key that is not the published id: %v", err)
+ }
+ swapped.KeyID = KeyID(der)
+ sig := f.Served.Signature
+ sig.KeyID = swapped.KeyID
+ if err := Verify(f.Served.Profile, sig, swapped); !errors.Is(err, ErrBadSignature) {
+ t.Fatalf("another key under its own id: %v", err)
+ }
+}
+
+func TestCheck(t *testing.T) {
+ f := load(t)
+ signed := true
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Path {
+ case "/api/v1/profiles/signing-key":
+ _ = json.NewEncoder(w).Encode(map[string]any{"key": f.Key})
+ case "/api/v1/profiles/community.ballbot":
+ doc := map[string]any{"profile": f.Served.Profile}
+ if signed {
+ doc["signature"] = f.Served.Signature
+ }
+ _ = json.NewEncoder(w).Encode(doc)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer srv.Close()
+ ctx := context.Background()
+
+ res, err := Check(ctx, srv.Client(), srv.URL, "community.ballbot", nil, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Plugin != "ballbot" || res.Version != "1" || res.KeyID != f.Key.KeyID {
+ t.Fatalf("%+v", res)
+ }
+ if _, err := Check(ctx, srv.Client(), srv.URL, "community.ballbot", f.Served.Profile, f.Key.KeyID); err != nil {
+ t.Fatalf("the same profile, the pinned key: %v", err)
+ }
+ if _, err := Check(ctx, srv.Client(), srv.URL, "community.ballbot", nil, "ffffffffffffffff"); err == nil || !strings.Contains(err.Error(), "pinned") {
+ t.Fatalf("another pinned key: %v", err)
+ }
+ sent := json.RawMessage(strings.Replace(string(f.Served.Profile), `"max_turn": 0.6`, `"max_turn": 1`, 1))
+ if _, err := Check(ctx, srv.Client(), srv.URL, "community.ballbot", sent, ""); err == nil || !strings.Contains(err.Error(), "not the signed one") {
+ t.Fatalf("a profile sent at pairing that is not the catalogue's: %v", err)
+ }
+ if _, err := Check(ctx, srv.Client(), srv.URL, "community.nope", nil, ""); err == nil || !strings.Contains(err.Error(), "not found") {
+ t.Fatalf("an unknown profile: %v", err)
+ }
+ if _, err := Check(ctx, srv.Client(), srv.URL, "adeept.adr036", nil, ""); err == nil || !strings.Contains(err.Error(), "not a community profile") {
+ t.Fatalf("a built-in profile: %v", err)
+ }
+ signed = false
+ if _, err := Check(ctx, srv.Client(), srv.URL, "community.ballbot", nil, ""); !errors.Is(err, ErrUnsigned) {
+ t.Fatalf("no signature: %v", err)
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
+{
+ "served": {
+ "profile": {
+ "id": "community.ballbot",
+ "version": 1,
+ "name": "Ballbot \"Mk\\2\" é ✓
\u0007 tab\there",
+ "vendor": "Mia",
+ "kind": "onboard",
+ "description": "A self-balancing ball robot on an ESP32, driven by a community Node plugin. <b>&</b>",
+ "capabilities": [
+ "drive.differential",
+ "battery"
+ ],
+ "mapping": {
+ "plugin": "ballbot",
+ "driver": "plugin"
+ },
+ "commands": {
+ "drive": {
+ "axes": {
+ "throttle": [
+ -1,
+ 1
+ ],
+ "steer": [
+ -1.5,
+ 1e+21,
+ 0.1,
+ 1e-7
+ ]
+ }
+ }
+ },
+ "widgets": [
+ {
+ "type": "drive",
+ "label": "Drive",
+ "capability": "drive.differential",
+ "order": 1,
+ "command": {
+ "kind": "drive"
+ }
+ }
+ ],
+ "limits": {
+ "max_turn": 0.6,
+ "max_speed": 0.5,
+ "max_command_ms": 300,
+ "heartbeat_ms": 1000,
+ "Zeta": null,
+ "alpha": true,
+ "é": 1,
+ "Z": 2
+ }
+ },
+ "signature": {
+ "alg": "Ed25519",
+ "key_id": "a7a0f02462c68119",
+ "value": "rnwR4vMilhtNtDPTNmpm2W3zPPfduwUVDPLry9WJyarnipsCJit/DlEkekDK3ZQqRnP5v8Yo5AMpjcWKTbufAQ=="
+ }
+ },
+ "key": {
+ "alg": "Ed25519",
+ "key_id": "a7a0f02462c68119",
+ "public_key_pem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA8NQGtoP2IvafI+EuePwk1L+bAf1ahWx65v474RSPoxM=\n-----END PUBLIC KEY-----\n"
+ },
+ "canonical": "{\"capabilities\":[\"drive.differential\",\"battery\"],\"commands\":{\"drive\":{\"axes\":{\"steer\":[-1.5,1e+21,0.1,1e-7],\"throttle\":[-1,1]}}},\"description\":\"A self-balancing ball robot on an ESP32, driven by a community Node plugin. <b>&</b>\",\"id\":\"community.ballbot\",\"kind\":\"onboard\",\"limits\":{\"Z\":2,\"Zeta\":null,\"alpha\":true,\"heartbeat_ms\":1000,\"max_command_ms\":300,\"max_speed\":0.5,\"max_turn\":0.6,\"é\":1},\"mapping\":{\"driver\":\"plugin\",\"plugin\":\"ballbot\"},\"name\":\"Ballbot \\\"Mk\\\\2\\\" é ✓
\\u0007 tab\\there\",\"vendor\":\"Mia\",\"version\":1,\"widgets\":[{\"capability\":\"drive.differential\",\"command\":{\"kind\":\"drive\"},\"label\":\"Drive\",\"order\":1,\"type\":\"drive\"}]}"
+}