The `golangci-lint` check was not using the config file, instead using defaults when running as part of `nix flake check`.
317 lines
9.0 KiB
Go
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
|
|
}
|