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:
- Recompute the SHA-256 hash over
module.Moduleand compare withmodule.Hash; mismatch yields"hash mismatch: expected …, got …". - If
SignerIDis non-empty,verifier.VerifyWithKey(SignerID, Module, Signature); otherwiseverifier.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
RequireHashVerificationbool
Intended to require a hash check. NOT read by Validate.
booltrueRequireSignaturebool
Intended to require a signature. NOT read by Validate. Source comment: "Will be enabled in next phase".
boolfalseAllowedHashes[]string
Intended allow-list of module hashes. NOT read by Validate.
[]string[] (empty)MaxModuleSizeint64
Maximum module size in bytes. The ONLY field Validate enforces; skipped entirely when <= 0.
int6410485760 (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",
}