Skip to content

Plugin Signing Specification

nSelf uses Ed25519 signatures to guarantee the integrity of every plugin distributed through ping.nself.org. This document specifies the exact canonical object that is signed and verified so that independent implementations (the TypeScript ping_api service and the Go CLI) produce and accept the same signature bytes.

Before signing or verifying, both sides construct the same canonical JSON string from the plugin’s manifest fields.

Exactly 6 fields are included, in this alphabetical order:

FieldJSON typeDescription
bundlestringThe bundle this plugin belongs to (e.g. nclaw, nchat)
namestringPlugin identifier as registered in the plugin registry
sha256stringLowercase hex-encoded SHA-256 digest of the plugin tarball
sizenumber (integer)Byte size of the plugin tarball
visibilitystring"public" or "private"
versionstringSemver string (e.g. 1.2.3)
  • Keys in strict alphabetical order: bundle, name, sha256, size, visibility, version
  • No whitespace — compact JSON serialization (JSON.stringify default)
  • UTF-8 encoding
  • No trailing newline
  • All string values are included verbatim — no normalization is applied

JSON object key order is not guaranteed by the spec, but both Go (encoding/json with alphabetically-declared struct fields) and JavaScript (JSON.stringify with a replacer array) can produce stable, deterministic output. Alphabetical order is the most auditable choice and matches the convention used in other certificate-style formats.

Given this plugin manifest:

bundle: nclaw
name: claw
sha256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
size: 102400
visibility: public
version: 1.2.3

The canonical object is:

{"bundle":"nclaw","name":"claw","sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","size":102400,"visibility":"public","version":"1.2.3"}

This exact byte string is passed to Ed25519 sign/verify — no hashing is applied on top of it. Ed25519 internally hashes the message before signing.

import { canonicalPluginObject, type PluginCanonicalFields } from './plugin-sign';
const fields: PluginCanonicalFields = {
bundle: 'nclaw',
name: 'claw',
sha256: 'e3b0c44...',
size: 102400,
visibility: 'public',
version: '1.2.3',
};
const canonical = canonicalPluginObject(fields);
// canonical === '{"bundle":"nclaw","name":"claw",...}'
// Pass canonical to ed25519.sign(privateKey, Buffer.from(canonical, 'utf8'))

Implementation: web/backend/services/ping_api/src/plugin-sign.ts

import "github.com/nself-org/nself/internal/plugin"
err := plugin.VerifyCanonicalSignature(
bundle, name, sha256, visibility, version,
size,
authorPublicKeyHex,
signatureHex,
)
if err != nil {
// plugin rejected — signature invalid or fields tampered
}

Implementation: cli/internal/plugin/verify.go

  • Signing algorithm: Ed25519 (RFC 8032)
  • Key format: Raw 32-byte public key / 64-byte private key (seed || public key), hex-encoded
  • Message: The canonical UTF-8 JSON string (no additional hashing — Ed25519 is its own digest)
  • Signature format: 64 bytes, hex-encoded

The shared golden fixture at cli/internal/plugin/testdata/canonical_golden.json contains the expected canonical string for the reference inputs in the worked example above. Both the Go test suite (verify_test.go) and the TypeScript test suite (tests/plugin-sign.test.ts) read this file and assert byte-identical output.

To run the cross-language compatibility checks:

Terminal window
# Go
cd cli && go test ./internal/plugin/ -run TestCanonicalPluginObject_GoldenFile -v
# TypeScript
cd web/backend/services/ping_api && pnpm test tests/plugin-sign.test.ts
  • The canonical object covers the tarball digest (sha256) and size, so any modification to the tarball content invalidates the signature.
  • The bundle and visibility fields are included so an attacker cannot re-sign a plugin as belonging to a different bundle or change its access tier without a new valid signature.
  • Signatures are generated once by the nSelf signing key at publish time and stored in the plugin registry. The CLI fetches and verifies the signature on every install.
  • The signing private key lives only in ~/.claude/vault.env on the nSelf build infrastructure and is never committed to any repository.