openvibers/openvibe.link Commits

Node: profile verify, and pairing checks a community robot profile against Bot's signature (#40)

OP OpenVibers committed 30 days ago · 40fb59cbcb · 7 files, +639 −0

The Node side of Bot's community robots (plan T15: profiles are "reviewed and signed before they run on anyone's Node"). Bot signs a community profile when an OpenVibe admin approves it (Bot server/submissions.js) and publishes its key. Until now nothing checked the signature.

**`internal/profilesig`**
- Canonical JSON exactly as Bot signs it:
  - keys sorted by UTF-16 code units, no whitespace;
  - strings escaped as `JSON.stringify` escapes them (U+2028 raw, control characters as `\u00xx`);
  - numbers as Bot wrote them (`1e+21`).
- Verification checks the key id (SHA-256 of the SPKI DER, first 16 hex digits), that the signature names that key, and Ed25519 over the canonical bytes.
- `Check` fetches the profile and the key from Bot. It can pin a key id, and can require that a profile Bot sent the Node is the catalogue's signed one.
- The test fixture was produced by Bot's own `canonicalJson` and `loadKey` with a fresh key. It has keys out of order, escapes, non-ASCII, U+2028, a control character and exponent-form numbers.

**CLI**
- `openvibe-node profile verify community.<name> [--server URL] [--key-id ID]` says whether the profile is the one that was reviewed and names its `mapping.plugin`. A failure exits 1 with "do not install its driver".
- `pair` (code or Network) runs the same check when the robot uses a community profile. A failure is a warning, not a refusal: the plugin is installed by hand.

**Docs:** README, and a "Community plugins" section in docs/plugins.md.

**Tests:** `internal/profilesig` (canonical form vs Bot's, tampering, wrong or swapped keys, pinning, unsigned, a different profile at pairing, unknown and built-in ids); `TestProfileVerify` (the CLI).

Changes

ddiff --git a/README.md b/README.md
iindex c3e2ace..9c55e6c 100644
---- a/README.md
++++ b/README.md
@@@ -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`: the
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)).
ddiff --git a/cmd/openvibe-node/main.go b/cmd/openvibe-node/main.go
iindex a9089f0..0ac9ba0 100644
---- a/cmd/openvibe-node/main.go
++++ b/cmd/openvibe-node/main.go
@@@ -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 stopped
openvibe-node resume release the stop
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
Global flags (before or after the command):
@@@ -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)")
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) int
err = 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)
++}
ddiff --git a/cmd/openvibe-node/profile_test.go b/cmd/openvibe-node/profile_test.go
nnew file mode 100644
iindex 0000000..64526b1
---- /dev/null
++++ b/cmd/openvibe-node/profile_test.go
@@@ -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)
++ }
++}
ddiff --git a/docs/plugins.md b/docs/plugins.md
iindex 4ffb205..38e13b8 100644
---- a/docs/plugins.md
++++ b/docs/plugins.md
@@@ -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
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.
++
++## 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.
ddiff --git a/internal/profilesig/profilesig.go b/internal/profilesig/profilesig.go
nnew file mode 100644
iindex 0000000..776055f
---- /dev/null
++++ b/internal/profilesig/profilesig.go
@@@ -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)
++}
ddiff --git a/internal/profilesig/profilesig_test.go b/internal/profilesig/profilesig_test.go
nnew file mode 100644
iindex 0000000..74edc8e
---- /dev/null
++++ b/internal/profilesig/profilesig_test.go
@@@ -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)
++ }
++}
ddiff --git a/internal/profilesig/testdata/ballbot.json b/internal/profilesig/testdata/ballbot.json
nnew file mode 100644
iindex 0000000..b821048
---- /dev/null
++++ b/internal/profilesig/testdata/ballbot.json
@@@ -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\"}]}"
++}