Files
brianmcgee 34dc8b4b1c nix: add .golangci.yml to the package source set
The `golangci-lint` check was not using the config file, instead using defaults when running as part of `nix flake check`.
2026-04-10 15:26:33 +01:00

317 lines
9.0 KiB
Go

package model
import (
"encoding/base64"
"encoding/binary"
"errors"
"fmt"
"io"
"time"
"git.clan.lol/clan/data-mesher/pkg/crypto"
"github.com/zeebo/blake3"
)
//go:generate enumer -type=SignatureType -json -transform=snake -trimprefix SignatureType -output=./signature_type_enum.go
type SignatureType byte //nolint:recvcheck
const (
SignatureTypeFile SignatureType = iota + 1
SignatureTypeTombstone
)
// ErrSignatureNotValid indicates that a provided signature is invalid.
var ErrSignatureNotValid = errors.New("invalid signature")
// Base64Bytes is a []byte that unmarshals from base64-encoded strings in HTTP headers.
type Base64Bytes []byte
// UnmarshalParam implements the echo.BindUnmarshaler interface for binding from headers/query params.
func (b *Base64Bytes) UnmarshalParam(param string) error {
decoded, err := base64.StdEncoding.DecodeString(param)
if err != nil {
return fmt.Errorf("failed to decode base64: %w", err)
}
*b = decoded
return nil
}
// Signature represents a signed file or tombstone.
type Signature struct {
Type SignatureType `codec:"type" json:"type"`
Name string `codec:"name" json:"name" param:"name"`
NetworkID *crypto.PublicKey `codec:"network_id" header:"X-Network-Id" json:"network_id"`
FileSize int64 `codec:"file_size" json:"file_size"`
SignedAt time.Time `codec:"signed_at" header:"X-Signedat" json:"signed_at"`
// ValidFor is an optional TTL signed by the author. When non-zero, the file
// is considered expired (and treated as not-found) on every node once
// SignedAt + ValidFor is in the past. Zero means "no expiry" — the file
// persists until explicitly deleted via tombstone.
//
// No `header` tag: echo's default binder cannot parse a duration from a
// string like "10m0s" (it tries strconv.ParseInt). The HTTP putFile
// handler parses the X-Validfor header manually after BindHeaders.
ValidFor time.Duration `codec:"valid_for,omitempty" json:"valid_for,omitempty"`
SignedBy *crypto.PublicKey `codec:"signed_by" header:"X-Signedby" json:"signed_by"`
Signature Base64Bytes `codec:"signature" header:"X-Signature" json:"signature"`
Certificate *crypto.Certificate `codec:"certificate,omitempty" header:"X-Certificate" json:"certificate,omitempty"`
}
// Expired returns true if the signature carries a non-zero ValidFor and the
// effective expiry (SignedAt + ValidFor) is before the given time.
func (s *Signature) Expired(now time.Time) bool {
return s.ValidFor > 0 && now.After(s.SignedAt.Add(s.ValidFor))
}
type SignatureWriter struct {
Type SignatureType
Name string
NetworkID *crypto.PublicKey
SignedAt time.Time
ValidFor time.Duration
size int64
hash []byte
hasher *blake3.Hasher
}
func (s *SignatureWriter) Write(p []byte) (int, error) {
if s.Type != SignatureTypeFile {
return 0, fmt.Errorf("invalid signature type: %v", s.Type)
}
if s.hasher == nil {
s.hasher = blake3.New()
}
n, err := s.hasher.Write(p)
if err != nil {
return 0, fmt.Errorf("failed to hash payload: %w", err)
}
s.size += int64(n)
return n, nil
}
func (s *SignatureWriter) Close() error {
if s.hasher != nil {
s.hash = s.hasher.Sum(nil)
}
return nil
}
// Size returns the number of bytes written to the SignatureWriter.
func (s *SignatureWriter) Size() int64 {
return s.size
}
func (s *SignatureWriter) Sign(key *crypto.PrivateKey) (*Signature, error) {
// Build buffer to sign: type + network_id + name + timestamp + size + hash + valid_for?
// Sizes: 1 + 32 + len(name) + 15 + 8 + 32 (+ 8 if ValidFor > 0)
buf := make([]byte, 0, 1+32+len(s.Name)+15+8+32+8)
buf = append(buf, byte(s.Type))
if s.NetworkID != nil {
buf = append(buf, s.NetworkID.Bytes()...)
}
buf = append(buf, []byte(s.Name)...)
signedAtBuf, err := s.SignedAt.UTC().MarshalBinary()
if err != nil {
return nil, fmt.Errorf("failed to marshal signed at: %w", err)
}
buf = append(buf, signedAtBuf...)
// Include size in signed data (big-endian int64)
sizeBuf := make([]byte, 8)
binary.BigEndian.PutUint64(sizeBuf, uint64(s.size)) //nolint:gosec // file sizes are always non-negative
buf = append(buf, sizeBuf...)
if s.hash != nil {
buf = append(buf, s.hash...)
}
// Conditional tail: only present when ValidFor is set. Keeps the signed
// buffer byte-identical to the pre-TTL format for sigs without expiry, so
// old and new nodes interoperate for non-expiring files.
if s.ValidFor > 0 {
validForBuf := make([]byte, 8)
binary.BigEndian.PutUint64(validForBuf, uint64(s.ValidFor))
buf = append(buf, validForBuf...)
}
sig := key.Sign(buf)
return &Signature{
Type: s.Type,
Name: s.Name,
NetworkID: s.NetworkID,
FileSize: s.size,
SignedAt: s.SignedAt,
ValidFor: s.ValidFor,
SignedBy: key.Public,
Signature: sig,
}, nil
}
func (s *SignatureWriter) Verify(key *crypto.PublicKey, sig []byte, size int64) (bool, error) {
// Build buffer to verify: type + network_id + name + timestamp + size + hash + valid_for?
// Sizes: 1 + 32 + len(name) + 15 + 8 + 32 (+ 8 if ValidFor > 0)
buf := make([]byte, 0, 1+32+len(s.Name)+15+8+32+8)
buf = append(buf, byte(s.Type))
buf = append(buf, s.NetworkID.Bytes()...)
buf = append(buf, []byte(s.Name)...)
signedAtBuf, err := s.SignedAt.UTC().MarshalBinary()
if err != nil {
return false, fmt.Errorf("failed to marshal signed at: %w", err)
}
buf = append(buf, signedAtBuf...)
// include size in verification buffer (big-endian int64)
sizeBuf := make([]byte, 8)
binary.BigEndian.PutUint64(sizeBuf, uint64(size)) //nolint:gosec // file sizes are always non-negative
buf = append(buf, sizeBuf...)
if s.hash != nil {
buf = append(buf, s.hash...)
}
// Conditional tail: mirror Sign exactly. A MITM that strips ValidFor from
// the struct in transit will cause this buffer to differ from what was
// signed, and ed25519 verification will fail.
if s.ValidFor > 0 {
validForBuf := make([]byte, 8)
binary.BigEndian.PutUint64(validForBuf, uint64(s.ValidFor))
buf = append(buf, validForBuf...)
}
return key.Verify(buf, sig), nil
}
// SignFile creates a signed file signature for the provided payload.
// The signature is computed over: type + network_id + name + timestamp + size +
// blake3(content). It is equivalent to SignFileWithTTL with validFor=0.
func SignFile(
name string,
networkID *crypto.PublicKey,
key *crypto.PrivateKey,
clock Clock,
r io.Reader,
) (*Signature, error) {
return SignFileWithTTL(name, networkID, key, clock, 0, r)
}
// SignFileWithTTL is like SignFile but also commits an optional TTL into the
// signature. When validFor is non-zero, the file is treated as expired by every
// node once SignedAt + validFor is in the past. When validFor is zero, this is
// byte-identical to a SignFile signature (the conditional tail is omitted), so
// non-expiring files are wire-compatible with the pre-TTL format.
func SignFileWithTTL(
name string,
networkID *crypto.PublicKey,
key *crypto.PrivateKey,
clock Clock,
validFor time.Duration,
r io.Reader,
) (*Signature, error) {
w := &SignatureWriter{
Type: SignatureTypeFile,
Name: name,
NetworkID: networkID,
SignedAt: clock(),
ValidFor: validFor,
}
if _, err := io.Copy(w, r); err != nil {
return nil, fmt.Errorf("failed to copy payload: %w", err)
}
if err := w.Close(); err != nil {
return nil, fmt.Errorf("failed to close signature writer: %w", err)
}
return w.Sign(key)
}
// SignTombstone creates a signed deletion record for a file.
// Tombstones propagate across the cluster to signal that a file should be deleted.
func SignTombstone(name string, networkID *crypto.PublicKey, key *crypto.PrivateKey, clock Clock) (*Signature, error) {
w := &SignatureWriter{
Type: SignatureTypeTombstone,
NetworkID: networkID,
Name: name,
SignedAt: clock(),
}
return w.Sign(key)
}
func (s *Signature) Verify(r io.Reader) error {
w := &SignatureWriter{
Type: s.Type,
NetworkID: s.NetworkID,
Name: s.Name,
SignedAt: s.SignedAt,
ValidFor: s.ValidFor,
}
if r != nil {
_, err := io.Copy(w, r)
if err != nil {
return fmt.Errorf("failed to copy payload: %w", err)
}
}
if err := w.Close(); err != nil {
return fmt.Errorf("failed to close signature writer: %w", err)
}
// for file signatures, verify the size matches what was signed
if s.Type == SignatureTypeFile && w.size != s.FileSize {
return fmt.Errorf("%w: size mismatch (got %d, expected %d)", ErrSignatureNotValid, w.size, s.FileSize)
}
valid, err := w.Verify(s.SignedBy, s.Signature, s.FileSize)
if err != nil {
return fmt.Errorf("failed to verify signature: %w", err)
}
if !valid {
return ErrSignatureNotValid
}
if s.Certificate != nil {
if !s.Certificate.IdentityKey.Equal(s.SignedBy) {
return fmt.Errorf("%w: certificate key does not match signing key", ErrSignatureNotValid)
}
if s.SignedAt.Before(s.Certificate.NotBefore) || s.SignedAt.After(s.Certificate.NotAfter) {
return fmt.Errorf("%w: signature time outside certificate validity", ErrSignatureNotValid)
}
if err = s.Certificate.Verify(s.NetworkID); err != nil {
return fmt.Errorf("%w: %w", ErrSignatureNotValid, err)
}
}
return nil
}