404 lines
12 KiB
Go
404 lines
12 KiB
Go
package crypto
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/libp2p/go-libp2p/core/peer"
|
|
)
|
|
|
|
const (
|
|
// nameSize is the fixed size for names in certificates (64 bytes).
|
|
nameSize = 64
|
|
|
|
// CertificateSize is the fixed size of a serialized certificate.
|
|
// Format: [pubkey (32)] [not_before (8)] [not_after (8)] [name (64)] [signature (64)] = 176 bytes.
|
|
CertificateSize = ed25519.PublicKeySize + 8 + 8 + nameSize + ed25519.SignatureSize // 176 bytes
|
|
|
|
// certificatePEMType is the PEM block type for data-mesher certificates.
|
|
certificatePEMType = "DATA-MESHER CERTIFICATE"
|
|
|
|
// timestampOffset is where timestamps begin in the serialized format.
|
|
timestampOffset = ed25519.PublicKeySize // 32
|
|
|
|
// nameOffset is where the machine name begins in the serialized format.
|
|
nameOffset = ed25519.PublicKeySize + 8 + 8 // 48
|
|
|
|
// signatureOffset is where the signature begins in the serialized format.
|
|
signatureOffset = ed25519.PublicKeySize + 8 + 8 + nameSize // 112
|
|
)
|
|
|
|
var (
|
|
ErrInvalidSignature = errors.New("invalid signature")
|
|
ErrInvalidCertificate = errors.New("invalid certificate")
|
|
)
|
|
|
|
// Certificate represents a CA-signed peer identity certificate with a validity period.
|
|
// It contains the peer's public key, name, validity timestamps, and the CA's signature over all fields.
|
|
type Certificate struct {
|
|
Name string // peer name (variable length, max 65535 bytes)
|
|
IdentityKey *PublicKey // peer public key (32 bytes)
|
|
NotBefore time.Time // the certificate is not valid before this time
|
|
NotAfter time.Time // the certificate is not valid after this time
|
|
Signature []byte // CA signature over pubkey || not_before || not_after || machine_name (64 bytes)
|
|
}
|
|
|
|
// SignCertificate creates a new certificate by signing a machine's public key with the CA's private key.
|
|
// The signature covers the public key, machine name, and validity timestamps.
|
|
func SignCertificate(
|
|
name string,
|
|
identityKey *PublicKey,
|
|
networkKey *PrivateKey,
|
|
notBefore, notAfter time.Time,
|
|
) (*Certificate, error) {
|
|
if name == "" {
|
|
return nil, errors.New("name is required")
|
|
}
|
|
|
|
if len(name) > nameSize {
|
|
return nil, fmt.Errorf("name exceeds maximum length of %d bytes", nameSize)
|
|
}
|
|
|
|
if identityKey == nil {
|
|
return nil, errors.New("identity key is required")
|
|
}
|
|
|
|
if networkKey == nil {
|
|
return nil, errors.New("network key is required")
|
|
}
|
|
|
|
if notAfter.Before(notBefore) {
|
|
return nil, errors.New("notAfter must be after notBefore")
|
|
}
|
|
|
|
cert := &Certificate{
|
|
Name: name,
|
|
IdentityKey: identityKey,
|
|
NotBefore: notBefore,
|
|
NotAfter: notAfter,
|
|
}
|
|
|
|
// sign the payload (pubkey || not_before || not_after || machine_name_len || machine_name)
|
|
cert.Signature = networkKey.Sign(cert.signedPayload())
|
|
|
|
return cert, nil
|
|
}
|
|
|
|
// ParseCertificate parses a certificate from its string representation.
|
|
// Accepts either a raw base64 string or PEM format.
|
|
func ParseCertificate(data string) (*Certificate, error) {
|
|
data = strings.TrimSpace(data)
|
|
|
|
// check if this looks like PEM format
|
|
pemHeader := "-----BEGIN " + certificatePEMType + "-----"
|
|
if strings.HasPrefix(data, pemHeader) {
|
|
// parse as PEM
|
|
block, _ := pem.Decode([]byte(data))
|
|
if block == nil {
|
|
return nil, errors.New("failed to decode PEM block")
|
|
}
|
|
|
|
if block.Type != certificatePEMType {
|
|
return nil, fmt.Errorf("unexpected PEM block type: %s, expected %s", block.Type, certificatePEMType)
|
|
}
|
|
|
|
return ParseCertificateBytes(block.Bytes)
|
|
}
|
|
|
|
// try to decode as raw base64
|
|
buf, err := base64.StdEncoding.DecodeString(data)
|
|
if err != nil {
|
|
// if base64 decoding fails, try wrapping in PEM headers
|
|
data = pemHeader + "\n" + data + "\n-----END " + certificatePEMType + "-----"
|
|
|
|
block, _ := pem.Decode([]byte(data))
|
|
if block == nil {
|
|
return nil, fmt.Errorf("failed to decode certificate as base64 or PEM: %w", err)
|
|
}
|
|
|
|
return ParseCertificateBytes(block.Bytes)
|
|
}
|
|
|
|
return ParseCertificateBytes(buf)
|
|
}
|
|
|
|
// ParseCertificateBytes parses a certificate from raw bytes.
|
|
func ParseCertificateBytes(buf []byte) (*Certificate, error) {
|
|
if len(buf) != CertificateSize {
|
|
return nil, fmt.Errorf("invalid certificate size: %d, expected %d", len(buf), CertificateSize)
|
|
}
|
|
|
|
// extract public key
|
|
pubKey, err := newPublicKey(buf[:ed25519.PublicKeySize])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse certificate public key: %w", err)
|
|
}
|
|
|
|
// extract timestamps
|
|
//nolint:gosec // G115: timestamps from valid certificates are reasonable dates
|
|
notBeforeUnix := int64(binary.BigEndian.Uint64(buf[timestampOffset:]))
|
|
//nolint:gosec // G115: timestamps from valid certificates are reasonable dates
|
|
notAfterUnix := int64(binary.BigEndian.Uint64(buf[timestampOffset+8:]))
|
|
|
|
// extract name (fixed size, trim null padding)
|
|
nameBytes := buf[nameOffset : nameOffset+nameSize]
|
|
// find first null byte
|
|
nullIndex := -1
|
|
|
|
for i, b := range nameBytes {
|
|
if b == 0 {
|
|
nullIndex = i
|
|
break
|
|
}
|
|
}
|
|
|
|
var machineName string
|
|
if nullIndex >= 0 {
|
|
machineName = string(nameBytes[:nullIndex])
|
|
} else {
|
|
machineName = string(nameBytes)
|
|
}
|
|
|
|
// extract signature
|
|
signature := make([]byte, ed25519.SignatureSize)
|
|
copy(signature, buf[signatureOffset:])
|
|
|
|
return &Certificate{
|
|
IdentityKey: pubKey,
|
|
Name: machineName,
|
|
NotBefore: time.Unix(notBeforeUnix, 0),
|
|
NotAfter: time.Unix(notAfterUnix, 0),
|
|
Signature: signature,
|
|
}, nil
|
|
}
|
|
|
|
// LoadCertificate loads a certificate from a file path or parses it from a base64 string.
|
|
func LoadCertificate(pathOrData string) (*Certificate, error) {
|
|
// first try to read as a file
|
|
data, err := os.ReadFile(pathOrData) //nolint:gosec
|
|
if err == nil {
|
|
// file exists, parse its contents
|
|
return ParseCertificate(string(data))
|
|
}
|
|
|
|
// not a file, try to parse as base64 string
|
|
return ParseCertificate(pathOrData)
|
|
}
|
|
|
|
// Verify checks if the certificate's signature is valid for the given CA public key and that the certificate is
|
|
// currently within its validity period.
|
|
func (c *Certificate) Verify(caPublicKey *PublicKey) error {
|
|
if err := c.VerifySignatureOnly(caPublicKey); err != nil {
|
|
return err
|
|
}
|
|
|
|
// check validity period
|
|
now := time.Now()
|
|
if now.Before(c.NotBefore) {
|
|
return fmt.Errorf(
|
|
"%w: certificate is not yet valid (valid from %s)",
|
|
ErrInvalidCertificate, c.NotBefore.UTC().Format(time.RFC3339),
|
|
)
|
|
}
|
|
|
|
if now.After(c.NotAfter) {
|
|
return fmt.Errorf(
|
|
"%w: certificate has expired (expired at %s)",
|
|
ErrInvalidCertificate, c.NotAfter.UTC().Format(time.RFC3339),
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// VerifySignatureOnly checks if the certificate's signature is valid for the given CA public key without checking the
|
|
// validity period. Use this when you need to verify a certificate regardless of time constraints
|
|
// (e.g., for debugging or historical verification).
|
|
func (c *Certificate) VerifySignatureOnly(caPublicKey *PublicKey) error {
|
|
if caPublicKey == nil {
|
|
return errors.New("ca public key is required")
|
|
}
|
|
|
|
if c.IdentityKey == nil {
|
|
return errors.New("certificate public key is required")
|
|
}
|
|
|
|
if len(c.Signature) != ed25519.SignatureSize {
|
|
return fmt.Errorf(
|
|
"%w: invalid signature size: %d, expected %d",
|
|
ErrInvalidSignature, len(c.Signature), ed25519.SignatureSize,
|
|
)
|
|
}
|
|
|
|
// verify the signature over the signed payload (pubkey + timestamps)
|
|
if !caPublicKey.Verify(c.signedPayload(), c.Signature) {
|
|
return fmt.Errorf("%w: signature verification failed", ErrInvalidSignature)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// IsExpired returns true if the certificate has expired.
|
|
func (c *Certificate) IsExpired() bool {
|
|
return time.Now().After(c.NotAfter)
|
|
}
|
|
|
|
// IsValid returns true if the certificate is currently within its validity period.
|
|
func (c *Certificate) IsValid() bool {
|
|
now := time.Now()
|
|
return !now.Before(c.NotBefore) && !now.After(c.NotAfter)
|
|
}
|
|
|
|
// Bytes returns the binary representation of the certificate.
|
|
// Format: [pubkey (32)] [not_before (8)] [not_after (8)] [machine_name (64)] [signature (64)].
|
|
func (c *Certificate) Bytes() []byte {
|
|
buf := make([]byte, CertificateSize)
|
|
|
|
// Add public key
|
|
copy(buf[:ed25519.PublicKeySize], c.IdentityKey.Bytes())
|
|
|
|
// Add validity timestamps
|
|
// Note: timestamps are always positive (post-1970) for valid certificates
|
|
//nolint:gosec // G115: certificate times are validated to be reasonable dates
|
|
binary.BigEndian.PutUint64(buf[timestampOffset:], uint64(c.NotBefore.Unix()))
|
|
//nolint:gosec // G115: certificate times are validated to be reasonable dates
|
|
binary.BigEndian.PutUint64(buf[timestampOffset+8:], uint64(c.NotAfter.Unix()))
|
|
|
|
// Add machine name (fixed size, null-padded)
|
|
copy(buf[nameOffset:], []byte(c.Name))
|
|
|
|
// Add signature
|
|
copy(buf[signatureOffset:], c.Signature)
|
|
|
|
return buf
|
|
}
|
|
|
|
// String returns the base64-encoded string representation of the certificate.
|
|
func (c *Certificate) String() string {
|
|
return base64.StdEncoding.EncodeToString(c.Bytes())
|
|
}
|
|
|
|
// PEM returns the PEM-encoded representation of the certificate.
|
|
func (c *Certificate) PEM() []byte {
|
|
return pem.EncodeToMemory(&pem.Block{
|
|
Type: certificatePEMType,
|
|
Bytes: c.Bytes(),
|
|
})
|
|
}
|
|
|
|
// PeerID derives the libp2p peer ID from the certificate's public key.
|
|
func (c *Certificate) PeerID() (peer.ID, error) {
|
|
p2pKey, err := c.IdentityKey.ToLibP2P()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to convert public key to libp2p: %w", err)
|
|
}
|
|
|
|
id, err := peer.IDFromPublicKey(p2pKey)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to derive peer ID: %w", err)
|
|
}
|
|
|
|
return id, nil
|
|
}
|
|
|
|
// MarshalBinary implements the encoding.BinaryMarshaler interface.
|
|
func (c *Certificate) MarshalBinary() ([]byte, error) {
|
|
return c.Bytes(), nil
|
|
}
|
|
|
|
// UnmarshalBinary implements the encoding.BinaryUnmarshaler interface.
|
|
func (c *Certificate) UnmarshalBinary(data []byte) error {
|
|
parsed, err := ParseCertificateBytes(data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
*c = *parsed
|
|
|
|
return nil
|
|
}
|
|
|
|
// MarshalJSON implements the json.Marshaler interface.
|
|
// Encodes the certificate as a base64 string.
|
|
func (c *Certificate) MarshalJSON() ([]byte, error) {
|
|
s := `"` + base64.StdEncoding.EncodeToString(c.Bytes()) + `"`
|
|
return []byte(s), nil
|
|
}
|
|
|
|
// UnmarshalJSON implements the json.Unmarshaler interface.
|
|
// Decodes the certificate from a base64 string.
|
|
func (c *Certificate) UnmarshalJSON(data []byte) error {
|
|
// strip quotes
|
|
if len(data) < 2 || data[0] != '"' || data[len(data)-1] != '"' {
|
|
return errors.New("certificate JSON must be a string")
|
|
}
|
|
|
|
b64 := string(data[1 : len(data)-1])
|
|
|
|
buf, err := base64.StdEncoding.DecodeString(b64)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to decode certificate base64: %w", err)
|
|
}
|
|
|
|
parsed, err := ParseCertificateBytes(buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
*c = *parsed
|
|
|
|
return nil
|
|
}
|
|
|
|
// UnmarshalParam implements the echo.BindUnmarshaler interface for HTTP header binding.
|
|
func (c *Certificate) UnmarshalParam(param string) error {
|
|
parsed, err := ParseCertificate(param)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
*c = *parsed
|
|
|
|
return nil
|
|
}
|
|
|
|
// signedPayload returns the bytes that are signed by the CA.
|
|
// Format: [pubkey (32)] [not_before (8)] [not_after (8)] [machine_name (64)].
|
|
func (c *Certificate) signedPayload() []byte {
|
|
buf := make([]byte, ed25519.PublicKeySize+8+8+nameSize)
|
|
offset := 0
|
|
|
|
// Add public key
|
|
copy(buf[offset:], c.IdentityKey.Bytes())
|
|
offset += ed25519.PublicKeySize
|
|
|
|
// Add timestamps
|
|
//nolint:gosec // G115: certificate times are validated to be reasonable dates
|
|
binary.BigEndian.PutUint64(buf[offset:], uint64(c.NotBefore.Unix()))
|
|
offset += 8
|
|
//nolint:gosec // G115: certificate times are validated to be reasonable dates
|
|
binary.BigEndian.PutUint64(buf[offset:], uint64(c.NotAfter.Unix()))
|
|
offset += 8
|
|
|
|
// Add machine name (fixed size, null-padded)
|
|
copy(buf[offset:], []byte(c.Name))
|
|
|
|
return buf
|
|
}
|
|
|
|
// WriteCertificate writes a certificate to a file.
|
|
func WriteCertificate(cert *Certificate, path string) error {
|
|
if err := os.WriteFile(path, []byte(cert.String()+"\n"), 0o600); err != nil {
|
|
return fmt.Errorf("failed to write certificate to file: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|