Skip to content
Sonr Crypto
Esc
navigateopen⌘Jpreview
On this page

WASM Module Signing

Ed25519 code signing and SHA-256 hash pinning for WebAssembly module bytes — supply-chain verification, not a JavaScript binding layer.

This is not a js/wasm binding layer

The package name misleads. github.com/sonr-io/crypto/wasm contains no //go:build js,wasm constraint, does not import syscall/js, and exposes nothing that runs inside a browser. Verified by reading both source files (signer.go, verifier.go): the only imports are crypto/ed25519, crypto/rand, crypto/sha256, encoding/base64, encoding/hex, encoding/json, fmt, sync, and time.

What it actually is: Ed25519 code signing and SHA-256 hash pinning over WebAssembly module bytes. It answers one question — is this .wasm blob the one I approved? — before a host embeds and executes it. That is supply-chain verification, and it is plain Go that compiles and runs on any target.

Reach for this when your program loads WASM plugins or modules from disk, a registry, or the network and must refuse anything it does not recognise.

Do not reach for this when you need sandboxing or capability control over what a module can do once loaded — that is the runtime’s job, not this package’s. Verification tells you which code you are about to run, never what it will do.

Trust model

Provision trust out of band

A SignatureVerifier starts empty and rejects everything with "no trusted keys configured". Verification is only as strong as the key set you install with AddTrustedKey / AddTrustedKeyFromHex. Those public keys must reach the verifier through a channel you already trust — baked into the binary, delivered by your config management, pinned in your deployment manifest. A key learned from the same place as the module buys you nothing.

Sign at build time

The publisher holds an Ed25519 private key and calls SignModule or CreateSignatureManifest over the exact bytes that will be shipped.

Verify at load time

The host recomputes the SHA-256 hash, compares it against the recorded one, and then checks the Ed25519 signature against a trusted key.

Pin hashes as an independent check

HashVerifier is deliberately separate from signing. A pinned hash constrains you to one exact build even if a signing key is later compromised — it is a second, non-overlapping control, not a weaker substitute for a signature.

Signing

func NewSigner() (*Signer, error)
func NewSignerFromPrivateKey(privateKey ed25519.PrivateKey) (*Signer, error)

func (s *Signer) Sign(wasmBytes []byte) ([]byte, error)
func (s *Signer) GetPublicKey() []byte
func (s *Signer) GetPublicKeyHex() string
func (s *Signer) ExportPrivateKey() []byte

NewSigner generates a fresh Ed25519 keypair from crypto/rand. NewSignerFromPrivateKey requires exactly ed25519.PrivateKeySize (64) bytes and derives the public key from it, erroring with "invalid private key size: expected 64, got N" otherwise. Sign produces a 64-byte ed25519.Sign(priv, wasmBytes) over the raw module bytes — not over the hash, and with no domain separation prefix.

Signed modules

type SignedModule struct {
	Module    []byte    `json:"-"`         // WASM bytecode, EXCLUDED from JSON
	Hash      string    `json:"hash"`      // hex SHA-256 of Module
	Signature []byte    `json:"signature"` // Ed25519, 64 bytes
	SignerID  string    `json:"signer_id"`
	Timestamp time.Time `json:"timestamp"`
	Version   string    `json:"version"`
}

func SignModule(signer *Signer, module []byte, signerID, version string) (*SignedModule, error)
func VerifySignedModule(verifier *SignatureVerifier, module *SignedModule) error

VerifySignedModule runs two checks in order:

  1. Recompute the SHA-256 hash over module.Module and compare with module.Hash; mismatch yields "hash mismatch: expected …, got …".
  2. If SignerID is non-empty, verifier.VerifyWithKey(SignerID, Module, Signature); otherwise verifier.Verify(Module, Signature), which tries every trusted key in turn.

Grounded in TestSignedModule (wasm/signer_test.go):

package main

import (
	"fmt"

	"github.com/sonr-io/crypto/wasm"
)

func main() {
	// Publisher side.
	signer, err := wasm.NewSigner()
	if err != nil {
		panic(err)
	}
	module := []byte("test wasm module") // in practice, the .wasm file contents

	signed, err := wasm.SignModule(signer, module, "test-signer", "v1.0.0")
	if err != nil {
		panic(err)
	}
	fmt.Println("hash:", signed.Hash)
	publicKeyHex := signer.GetPublicKeyHex() // ship this out of band

	// Host side: trust is provisioned from the out-of-band key, not from `signed`.
	verifier := wasm.NewSignatureVerifier()
	if err := verifier.AddTrustedKeyFromHex("test-signer", publicKeyHex); err != nil {
		panic(err)
	}
	fmt.Println("trusted:", verifier.GetTrustedKeyIDs())

	fmt.Println("ok:", wasm.VerifySignedModule(verifier, signed)) // nil

	// Tampering is caught at the hash check.
	signed.Module = []byte("tampered")
	fmt.Println("tampered:", wasm.VerifySignedModule(verifier, signed)) // "hash mismatch"
}

The SignatureVerifier

func NewSignatureVerifier() *SignatureVerifier
func (v *SignatureVerifier) AddTrustedKey(keyID string, publicKey ed25519.PublicKey) error
func (v *SignatureVerifier) AddTrustedKeyFromHex(keyID, publicKeyHex string) error
func (v *SignatureVerifier) RemoveTrustedKey(keyID string)
func (v *SignatureVerifier) GetTrustedKeyIDs() []string
func (v *SignatureVerifier) Verify(wasmBytes, signature []byte) error
func (v *SignatureVerifier) VerifyWithKey(keyID string, wasmBytes, signature []byte) error

AddTrustedKey requires exactly ed25519.PublicKeySize (32) bytes. The map is guarded by a sync.RWMutex, so a verifier is safe for concurrent use.

Prefer VerifyWithKey over Verify. Verify iterates the whole trusted set and succeeds if any key validates, so it tells you the module is signed by someone you trust but not by whom — and it does not report which key matched. VerifyWithKey binds the check to an expected signer.

Manifests

A manifest decouples signature metadata from the module file, and supports multiple signatures.

type SignatureManifest struct {
	ModuleHash  string            `json:"module_hash"`
	Signatures  []SignatureEntry  `json:"signatures"`
	TrustedKeys []TrustedKeyEntry `json:"trusted_keys"`
	CreatedAt   time.Time         `json:"created_at"`
	ExpiresAt   *time.Time        `json:"expires_at,omitempty"`
}

type SignatureEntry struct {
	Signature string    `json:"signature"` // base64 std encoding
	SignerID  string    `json:"signer_id"`
	Timestamp time.Time `json:"timestamp"`
	Algorithm string    `json:"algorithm"` // always "Ed25519"
}

type TrustedKeyEntry struct {
	KeyID     string     `json:"key_id"`
	PublicKey string     `json:"public_key"` // base64 std encoding
	AddedAt   time.Time  `json:"added_at"`
	ExpiresAt *time.Time `json:"expires_at,omitempty"`
	Purpose   string     `json:"purpose"`    // e.g. "code-signing"
}

func CreateSignatureManifest(module []byte, signer *Signer, signerID string) (*SignatureManifest, error)
func ExportManifest(manifest *SignatureManifest) ([]byte, error)
func ImportManifest(data []byte) (*SignatureManifest, error)
func VerifyWithManifest(module []byte, manifest *SignatureManifest) error

CreateSignatureManifest emits a manifest with exactly one SignatureEntry and one TrustedKeyEntry (Purpose: "code-signing"). VerifyWithManifest checks the module hash, then ExpiresAt on the manifest, then builds a fresh verifier from manifest.TrustedKeys, skipping any entry whose own ExpiresAt has passed.

Hash pinning

func NewHashVerifier() *HashVerifier
func (v *HashVerifier) ComputeHash(wasmBytes []byte) string      // hex SHA-256
func (v *HashVerifier) AddTrustedHash(name, hash string)
func (v *HashVerifier) GetTrustedHash(name string) (string, bool)
func (v *HashVerifier) VerifyHash(name string, wasmBytes []byte) error
func (v *HashVerifier) VerifyHashWithFallback(name string, wasmBytes []byte, fallbackHashes []string) error
func (v *HashVerifier) ClearTrustedHashes()

VerifyHash errors with "no trusted hash found for WASM module: <name>" when the name is unknown — so an unregistered module is denied by default, which is the right behaviour. The map is sync.RWMutex-guarded.

Hash chains

type HashEntry struct {
	Version      string `json:"version"`
	Hash         string `json:"hash"`
	PreviousHash string `json:"previous_hash"`
	Timestamp    int64  `json:"timestamp"`
}

func NewHashChain() *HashChain
func (hc *HashChain) AddEntry(version, hash string, timestamp int64) error
func (hc *HashChain) GetLatestEntry() (*HashEntry, error)
func (hc *HashChain) VerifyChain() error

AddEntry appends an entry whose PreviousHash is copied from the previous entry’s Hash (empty for the first). VerifyChain accepts an empty chain, requires the first entry’s PreviousHash to be empty, and then checks that each PreviousHash equals the preceding Hash. GetLatestEntry returns a copy of the last entry, or "hash chain is empty".

SecurityPolicy

PropType
RequireHashVerificationbool

Intended to require a hash check. NOT read by Validate.

Typebool
Defaulttrue
RequireSignaturebool

Intended to require a signature. NOT read by Validate. Source comment: "Will be enabled in next phase".

Typebool
Defaultfalse
AllowedHashes[]string

Intended allow-list of module hashes. NOT read by Validate.

Type[]string
Default[] (empty)
MaxModuleSizeint64

Maximum module size in bytes. The ONLY field Validate enforces; skipped entirely when <= 0.

Typeint64
Default10485760 (10 MiB)

VerificationError

A structured error type for reporting a failed check. It is exported and its Error() renders module, reason, expected and actual hash — but no function in the package returns it. Every failure path uses fmt.Errorf instead. Use it in your own verification wrapper if you want typed errors:

return &wasm.VerificationError{
	Module:       name,
	ExpectedHash: expected,
	ActualHash:   verifier.ComputeHash(moduleBytes),
	Reason:       "pinned hash mismatch",
}

Caveats summary

Next

Last updated on September 2, 2026

Was this page helpful?