niko_trust/internal/identity/signer/signer.go
Niko Marmeladkov 9d66003689
Initial commit: signed-object trust relay, verifier, and docs
- server: relay storing signed objects (PUT/GET), per-IP rate limiting,
  per-subject quota (1000), one-response-per-request, pagination,
  /v1/healthz /v1/readyz /v1/metrics
- verify: signature-verifying trust evaluator; every object is checked via
  env.Verify(), approvals via VerifyApprovalResponse, revocations via
  VerifyRevocationOf; k-of-n approval quorum
- docs: TRUST-MODEL.md and API.md describing issuer-anchored signatures and
  the endpoint/status-code contract
- tests: server, verify, and ratelimit packages
2026-08-12 22:36:49 +03:00

182 lines
6.1 KiB
Go

// Package signer holds the only private key handling in the tree.
//
// CLIENT-SIDE ONLY. Server-side packages must never import this package.
// The rule is mechanically enforced by TestServerPackagesHaveNoSigner in the
// api layer and by TestSignerIsNotReachableFromProtocol here, so a violation
// fails the build rather than merely contradicting a comment.
//
// The separation exists because of INV-1: a compromise of trust.n1ko.dev must
// not allow forging an identity, claim, approval or response. That property
// holds only if the server never possesses a signing key. Keeping the signing
// capability in a package the server does not link makes the guarantee
// structural instead of a matter of discipline.
//
// It also serves INV-2: the server is never an issuer. It has nothing to issue
// with.
package signer
import (
"crypto"
"crypto/ed25519"
"crypto/rand"
"errors"
"io"
"git.n1ko.dev/Niko/niko_trust/internal/address"
"git.n1ko.dev/Niko/niko_trust/internal/identity"
)
var (
// ErrSeedSize is returned when a seed is not 32 bytes.
ErrSeedSize = errors.New("signer: seed must be 32 bytes")
// ErrKeySize is returned when a private key is not 64 bytes.
ErrKeySize = errors.New("signer: private key must be 64 bytes")
// ErrKeyMismatch is returned when a private key's embedded public half
// does not match the public key derived from its seed. Such a key would
// produce signatures that do not verify under the advertised identity.
ErrKeyMismatch = errors.New("signer: private key is inconsistent")
)
// Signer owns an Ed25519 private key and can sign canonical protocol bytes.
//
// The zero Signer is unusable. Signer is deliberately not comparable to a
// public type and provides no accessor returning the raw private key other
// than the explicitly named Seed and PrivateKey methods, which exist only so
// that the keystore can persist the key.
type Signer struct {
priv ed25519.PrivateKey
id identity.Identity
}
// Generate creates a new identity and its private key using crypto/rand.
//
// The generated public key is validated exactly as an imported one would be.
// The probability of ed25519.GenerateKey producing a small-order key is
// negligible, but the check costs nothing and removes the need to reason about
// whether this path is special.
func Generate() (*Signer, error) {
return GenerateFrom(rand.Reader)
}
// GenerateFrom creates a new identity using the supplied entropy source.
//
// Callers outside tests should use Generate. Passing a source that is not
// cryptographically secure produces a key an attacker can reproduce.
func GenerateFrom(random io.Reader) (*Signer, error) {
if random == nil {
random = rand.Reader
}
pub, priv, err := ed25519.GenerateKey(random)
if err != nil {
return nil, err
}
return newSigner(priv, pub)
}
// FromSeed reconstructs a Signer from a 32-byte seed.
func FromSeed(seed []byte) (*Signer, error) {
if len(seed) != ed25519.SeedSize {
return nil, ErrSeedSize
}
priv := ed25519.NewKeyFromSeed(seed)
pub, ok := priv.Public().(ed25519.PublicKey)
if !ok {
return nil, ErrKeySize
}
return newSigner(priv, pub)
}
// FromPrivateKey reconstructs a Signer from a 64-byte Ed25519 private key.
//
// The key's embedded public half is checked against the public key derived
// from its seed. Without that check a malformed or tampered key file would
// yield a Signer whose signatures never verify under the identity it reports,
// which is a confusing failure to debug and a plausible way to trick a user
// into believing an action succeeded.
func FromPrivateKey(priv ed25519.PrivateKey) (*Signer, error) {
if len(priv) != ed25519.PrivateKeySize {
return nil, ErrKeySize
}
derived := ed25519.NewKeyFromSeed(priv.Seed())
if subtleCompare(derived, priv) != 1 {
return nil, ErrKeyMismatch
}
pub, ok := priv.Public().(ed25519.PublicKey)
if !ok {
return nil, ErrKeySize
}
return newSigner(priv, pub)
}
// newSigner validates the public half and builds the Signer.
func newSigner(priv ed25519.PrivateKey, pub ed25519.PublicKey) (*Signer, error) {
if err := address.ValidatePubKey(pub); err != nil {
return nil, err
}
id, err := identity.FromPubKey(pub)
if err != nil {
return nil, err
}
// Copy so that the caller's slice cannot be mutated underneath us.
stored := make(ed25519.PrivateKey, len(priv))
copy(stored, priv)
return &Signer{priv: stored, id: id}, nil
}
// Identity returns the public identity corresponding to the private key.
func (s *Signer) Identity() identity.Identity { return s.id }
// Address returns the signer's trust address.
func (s *Signer) Address() address.Address { return s.id.Address() }
// Public returns the public key.
func (s *Signer) Public() ed25519.PublicKey { return s.id.PubKey() }
// Sign signs msg with the private key.
//
// msg must be the canonical encoding of a protocol object, produced by the
// protocol layer, and must already carry that layer's domain separation tag.
// This package intentionally adds no framing of its own: adding a second,
// invisible layer of framing here would make the signed bytes depend on which
// code path produced them, which is precisely the ambiguity INV-8 forbids.
func (s *Signer) Sign(msg []byte) []byte {
return ed25519.Sign(s.priv, msg)
}
// SignerCrypto exposes the key as a crypto.Signer for interoperability.
func (s *Signer) SignerCrypto() crypto.Signer { return s.priv }
// Seed returns a copy of the 32-byte seed for persistence by the keystore.
//
// The name is blunt on purpose: a call site that reads Seed() is obviously
// handling secret material and should be scrutinised in review.
func (s *Signer) Seed() []byte {
seed := s.priv.Seed()
out := make([]byte, len(seed))
copy(out, seed)
return out
}
// PrivateKey returns a copy of the full private key for persistence.
func (s *Signer) PrivateKey() ed25519.PrivateKey {
out := make(ed25519.PrivateKey, len(s.priv))
copy(out, s.priv)
return out
}
// subtleCompare is a constant-time equality test returning 1 when equal.
func subtleCompare(a, b []byte) int {
if len(a) != len(b) {
return 0
}
var v byte
for i := range a {
v |= a[i] ^ b[i]
}
if v == 0 {
return 1
}
return 0
}