diff --git a/driver/driver.go b/driver/driver.go index 74b9aed..803af12 100644 --- a/driver/driver.go +++ b/driver/driver.go @@ -382,6 +382,66 @@ func (d *Driver) EnrollRecovery(enrollment, enrollmentSig string) (map[string]in return d.jsonRPC(msg, cmdEnrollRecoveryOK, "enroll_recovery") } +const ( + // maxEnvelopeLen bounds a canonical reqsig envelope; real envelopes + // are <200 bytes (12-char domain, 12 hex addr, decimal ts, 16 hex + // nonce, 64 hex hash, <=64 char audience, 5 pipes). + maxEnvelopeLen = 512 + // maxSigB64Len bounds a base64 ed25519 signature (88 chars). + maxSigB64Len = 128 +) + +// SignEnvelope asks the daemon to sign a request-signature envelope +// (common/reqsig) for the given audience over the given body hash (64 +// lowercase hex chars — sha256 of the request body, see reqsig.HashBody). +// The daemon constructs the envelope itself — its own address, the current +// timestamp, a fresh nonce — and signs only the reqsig canonical form; it +// never signs caller-supplied raw strings. Returns {envelope, signature, +// address}. +func (d *Driver) SignEnvelope(audience, bodyHash string) (map[string]interface{}, error) { + if len(bodyHash) != 64 { + return nil, fmt.Errorf("sign_envelope: body hash must be 64 hex chars (sha256)") + } + if audience == "" || len(audience) > 64 { + return nil, fmt.Errorf("sign_envelope: audience must be 1-64 chars") + } + data, _ := json.Marshal(map[string]string{"audience": audience, "body_hash": bodyHash}) + msg := append([]byte{cmdSignEnvelope}, data...) + return d.jsonRPC(msg, cmdSignEnvelopeOK, "sign_envelope") +} + +// VerifyEnvelope checks a canonical reqsig envelope + base64 signature via +// the daemon, which resolves the claimed node's key from its local cache +// first and the registry on miss. With checkStanding the daemon also reports +// the signer's registry standing (online, last_seen_unix, key_generation, +// network_member) when the registry provides it. A failed check is NOT an +// error — the reply carries valid=false plus a reason. +func (d *Driver) VerifyEnvelope(envelope, sigB64 string, checkStanding bool) (map[string]interface{}, error) { + return d.VerifyEnvelopeMaxSkew(envelope, sigB64, checkStanding, 0) +} + +// VerifyEnvelopeMaxSkew is VerifyEnvelope with an explicit freshness window +// in seconds. 0 selects the daemon default (reqsig.DefaultMaxSkew). +func (d *Driver) VerifyEnvelopeMaxSkew(envelope, sigB64 string, checkStanding bool, maxSkewSecs uint32) (map[string]interface{}, error) { + // Canonical envelopes are <200 bytes and a base64 ed25519 signature is + // 88 chars; bounding both rejects garbage client-side and keeps the + // request frame allocation size independent of caller input. + if envelope == "" || len(envelope) > maxEnvelopeLen { + return nil, fmt.Errorf("verify_envelope: envelope must be 1-%d bytes", maxEnvelopeLen) + } + if sigB64 == "" || len(sigB64) > maxSigB64Len { + return nil, fmt.Errorf("verify_envelope: signature must be 1-%d bytes", maxSigB64Len) + } + data, _ := json.Marshal(map[string]interface{}{ + "envelope": envelope, + "signature": sigB64, + "check_standing": checkStanding, + "max_skew_secs": maxSkewSecs, + }) + msg := append([]byte{cmdVerifyEnvelope}, data...) + return d.jsonRPC(msg, cmdVerifyEnvelopeOK, "verify_envelope") +} + // Disconnect closes a connection by ID. Used by administrative tools. // Fire-and-forget: the daemon always responds CmdCloseOK regardless of // whether the connID exists, so there is no error to propagate. Using diff --git a/driver/ipc.go b/driver/ipc.go index eb55c59..b9e3aa0 100644 --- a/driver/ipc.go +++ b/driver/ipc.go @@ -69,6 +69,16 @@ const ( cmdSubmitBadgeOK byte = 0x30 cmdEnrollRecovery byte = 0x31 cmdEnrollRecoveryOK byte = 0x32 + // cmdSignEnvelope asks the daemon to sign a request-signature envelope + // (common/reqsig) naming its OWN address; cmdVerifyEnvelope checks a + // peer-produced envelope + signature against the peer's registered key. + // The daemon never signs caller-supplied raw strings — it constructs + // the envelope itself from the audience + body hash. Older daemons + // without these handlers reply cmdError. + cmdSignEnvelope byte = 0x33 + cmdSignEnvelopeOK byte = 0x34 + cmdVerifyEnvelope byte = 0x35 + cmdVerifyEnvelopeOK byte = 0x36 ) // Network sub-commands (must match daemon SubNetwork* constants) @@ -232,7 +242,8 @@ func (c *ipcClient) readLoop() { cmdResolveHostnameOK, cmdSetHostnameOK, cmdSetVisibilityOK, cmdDeregisterOK, cmdSetTagsOK, cmdSetWebhookOK, cmdNetworkOK, cmdHealthOK, cmdManagedOK, cmdRotateKeyOK, cmdBroadcastOK, - cmdPreferDirectOK, cmdSubmitBadgeOK, cmdEnrollRecoveryOK: + cmdPreferDirectOK, cmdSubmitBadgeOK, cmdEnrollRecoveryOK, + cmdSignEnvelopeOK, cmdVerifyEnvelopeOK: // Known response cmds: deliver to the active sendAndWait waiter. // If there is no active waiter (the request timed out / was // abandoned, or this is a duplicate), the reply is dropped — @@ -354,6 +365,9 @@ func (c *ipcClient) cleanup() { // writeFrame builds a `[cmd][body...]` envelope and writes it under // writeMu so frames don't interleave on the wire. func (c *ipcClient) writeFrame(cmd byte, body []byte) error { + if len(body) > ipcutil.MaxMessageSize-ipcEnvelopeHeaderSize { + return fmt.Errorf("ipc frame too large: %d bytes", len(body)) + } buf := make([]byte, ipcEnvelopeHeaderSize+len(body)) buf[0] = cmd copy(buf[1:], body) diff --git a/driver/zz_driver_envelope_test.go b/driver/zz_driver_envelope_test.go new file mode 100644 index 0000000..1065695 --- /dev/null +++ b/driver/zz_driver_envelope_test.go @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package driver + +import ( + "encoding/json" + "testing" +) + +// TestDriverSignEnvelope covers SignEnvelope's JSON-RPC roundtrip: the +// request frame is [cmdSignEnvelope][JSON{audience,body_hash}] and the +// cmdSignEnvelopeOK reply is routed by readLoop's allowlist and +// unmarshalled — a new response opcode readLoop doesn't allowlist would +// silently hang here. +func TestDriverSignEnvelope(t *testing.T) { + t.Parallel() + d := newFakeDaemon(t) + defer d.close() + + const wantAudience = "svc.example.io" + const wantHash = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + + d.onCmd(cmdSignEnvelope, func(frame []byte) [][]byte { + if frame[0] != cmdSignEnvelope { + t.Errorf("opcode = 0x%02X, want 0x%02X", frame[0], cmdSignEnvelope) + } + var got map[string]string + if err := json.Unmarshal(frame[1:], &got); err != nil { + t.Errorf("payload not JSON: %v", err) + return [][]byte{{cmdError, 'b', 'a', 'd'}} + } + if got["audience"] != wantAudience || got["body_hash"] != wantHash { + t.Errorf("payload = %+v, want audience=%q body_hash=%q", got, wantAudience, wantHash) + } + body := []byte(`{"type":"sign_envelope_ok","envelope":"pilot-req-v1|...","signature":"c2ln","address":"0:0000.0000.0007"}`) + return [][]byte{append([]byte{cmdSignEnvelopeOK}, body...)} + }) + + drv, err := Connect(d.path) + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer drv.Close() + + result, err := drv.SignEnvelope(wantAudience, wantHash) + if err != nil { + t.Fatalf("SignEnvelope: %v", err) + } + if sig, _ := result["signature"].(string); sig != "c2ln" { + t.Errorf("result = %+v, want signature=c2ln", result) + } + if addr, _ := result["address"].(string); addr != "0:0000.0000.0007" { + t.Errorf("address = %v", result["address"]) + } +} + +// TestDriverSignEnvelopeEmptyBodyHash pins the client-side guard: an empty +// body hash is refused before any IPC frame goes out. +func TestDriverSignEnvelopeEmptyBodyHash(t *testing.T) { + t.Parallel() + d := newFakeDaemon(t) + defer d.close() + + drv, err := Connect(d.path) + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer drv.Close() + + if _, err := drv.SignEnvelope("svc.example.io", ""); err == nil { + t.Fatal("SignEnvelope with empty body hash should fail") + } +} + +// TestDriverVerifyEnvelope covers VerifyEnvelope's JSON-RPC roundtrip and the +// wire shape of the optional fields (check_standing, max_skew_secs default 0). +func TestDriverVerifyEnvelope(t *testing.T) { + t.Parallel() + d := newFakeDaemon(t) + defer d.close() + + const wantEnvelope = "pilot-req-v1|000000000007|1|00112233aabbccdd|hash|svc.example.io" + const wantSig = "c2ln" + + d.onCmd(cmdVerifyEnvelope, func(frame []byte) [][]byte { + var got map[string]interface{} + if err := json.Unmarshal(frame[1:], &got); err != nil { + t.Errorf("payload not JSON: %v", err) + return [][]byte{{cmdError, 'b', 'a', 'd'}} + } + if got["envelope"] != wantEnvelope || got["signature"] != wantSig { + t.Errorf("payload = %+v", got) + } + if cs, _ := got["check_standing"].(bool); cs { + t.Errorf("check_standing = %v, want false", got["check_standing"]) + } + if skew, _ := got["max_skew_secs"].(float64); skew != 0 { + t.Errorf("max_skew_secs = %v, want 0 (daemon default)", got["max_skew_secs"]) + } + body := []byte(`{"type":"verify_envelope_ok","valid":true,"node_id":7,"verified_via":"cache","trusted":false}`) + return [][]byte{append([]byte{cmdVerifyEnvelopeOK}, body...)} + }) + + drv, err := Connect(d.path) + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer drv.Close() + + result, err := drv.VerifyEnvelope(wantEnvelope, wantSig, false) + if err != nil { + t.Fatalf("VerifyEnvelope: %v", err) + } + if valid, _ := result["valid"].(bool); !valid { + t.Errorf("result = %+v, want valid=true", result) + } + if via, _ := result["verified_via"].(string); via != "cache" { + t.Errorf("verified_via = %v", result["verified_via"]) + } +} + +// TestDriverVerifyEnvelopeMaxSkewAndStanding covers the explicit-skew variant +// and standing passthrough. +func TestDriverVerifyEnvelopeMaxSkewAndStanding(t *testing.T) { + t.Parallel() + d := newFakeDaemon(t) + defer d.close() + + d.onCmd(cmdVerifyEnvelope, func(frame []byte) [][]byte { + var got map[string]interface{} + if err := json.Unmarshal(frame[1:], &got); err != nil { + t.Errorf("payload not JSON: %v", err) + return [][]byte{{cmdError, 'b', 'a', 'd'}} + } + if cs, _ := got["check_standing"].(bool); !cs { + t.Errorf("check_standing = %v, want true", got["check_standing"]) + } + if skew, _ := got["max_skew_secs"].(float64); skew != 600 { + t.Errorf("max_skew_secs = %v, want 600", got["max_skew_secs"]) + } + body := []byte(`{"type":"verify_envelope_ok","valid":false,"reason":"reqsig: envelope timestamp outside window"}`) + return [][]byte{append([]byte{cmdVerifyEnvelopeOK}, body...)} + }) + + drv, err := Connect(d.path) + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer drv.Close() + + result, err := drv.VerifyEnvelopeMaxSkew("env", "sig", true, 600) + if err != nil { + t.Fatalf("VerifyEnvelopeMaxSkew: %v", err) + } + // A failed check is a verdict, not an error. + if valid, _ := result["valid"].(bool); valid { + t.Errorf("result = %+v, want valid=false", result) + } + if reason, _ := result["reason"].(string); reason == "" { + t.Errorf("reason missing: %+v", result) + } +} diff --git a/driver/zz_envelope_bounds_test.go b/driver/zz_envelope_bounds_test.go new file mode 100644 index 0000000..4be4542 --- /dev/null +++ b/driver/zz_envelope_bounds_test.go @@ -0,0 +1,43 @@ +package driver + +import ( + "strings" + "testing" +) + +// The input bounds reject garbage client-side, before any daemon round trip, +// so a nil driver is safe: reaching jsonRPC would panic and fail the test. +func TestSignEnvelopeInputBounds(t *testing.T) { + var d *Driver + hash := strings.Repeat("ab", 32) + + if _, err := d.SignEnvelope("svc", "beef"); err == nil { + t.Fatal("expected error for short body hash") + } + if _, err := d.SignEnvelope("svc", ""); err == nil { + t.Fatal("expected error for empty body hash") + } + if _, err := d.SignEnvelope("", hash); err == nil { + t.Fatal("expected error for empty audience") + } + if _, err := d.SignEnvelope(strings.Repeat("a", 65), hash); err == nil { + t.Fatal("expected error for oversized audience") + } +} + +func TestVerifyEnvelopeInputBounds(t *testing.T) { + var d *Driver + + if _, err := d.VerifyEnvelope("", "c2ln", false); err == nil { + t.Fatal("expected error for empty envelope") + } + if _, err := d.VerifyEnvelope(strings.Repeat("x", maxEnvelopeLen+1), "c2ln", false); err == nil { + t.Fatal("expected error for oversized envelope") + } + if _, err := d.VerifyEnvelope("pilot-req-v1|x", "", false); err == nil { + t.Fatal("expected error for empty signature") + } + if _, err := d.VerifyEnvelope("pilot-req-v1|x", strings.Repeat("A", maxSigB64Len+1), false); err == nil { + t.Fatal("expected error for oversized signature") + } +} diff --git a/ipcutil/ipcutil.go b/ipcutil/ipcutil.go index fb65a50..1c2698a 100644 --- a/ipcutil/ipcutil.go +++ b/ipcutil/ipcutil.go @@ -49,6 +49,11 @@ func Read(r io.Reader) ([]byte, error) { // daemon sends. For genuine shared-writer concurrency at the higher // layer, wrap the writer with your own mutex. func Write(w io.Writer, data []byte) error { + // Symmetric with Read: a frame the peer would reject on size is + // refused before allocation rather than written and dropped there. + if len(data) > MaxMessageSize { + return fmt.Errorf("ipc message too large: %d bytes (max %d)", len(data), MaxMessageSize) + } frame := make([]byte, 4+len(data)) binary.BigEndian.PutUint32(frame[:4], uint32(len(data))) copy(frame[4:], data) diff --git a/reqsig/reqsig.go b/reqsig/reqsig.go new file mode 100644 index 0000000..4e3f8af --- /dev/null +++ b/reqsig/reqsig.go @@ -0,0 +1,257 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Package reqsig implements the canonical request-signature envelope used to +// prove that a request originates from a registered Pilot node, and the +// registry-signed verdict returned by verification endpoints. +// +// Envelope canonical form (all fields fixed-charset so '|' can never occur +// inside a field): +// +// pilot-req-v1|aaaaaaaaaaaa|ts|nnnnnnnnnnnnnnnn|hhhh…64…hhhh|audience +// +// aaaa… 12 lowercase hex chars: 4 for the 16-bit network, +// 8 for the 32-bit node ID (the Pilot address, no separators — +// the text address format "N:NNNN.HHHH.LLLL" contains ':' and +// '.' and is deliberately NOT used here) +// ts canonical decimal unix seconds (no sign, no leading zeros) +// nonce 16 lowercase hex chars (8 random bytes) +// body hash 64 lowercase hex chars, sha256 of the request body +// audience [a-z0-9.-]{1,64}, the consuming service's identifier +// +// The domain prefix provides domain separation: a signature over an envelope +// can never be confused with a protocol-internal signature (heartbeats, key +// exchange, badges), and signers MUST refuse to sign anything that is not a +// well-formed envelope naming their own address. +// +// Freshness: verifiers reject envelopes whose timestamp is outside a skew +// window (DefaultMaxSkew). Nonce dedup within the window is the consuming +// service's responsibility; the registry echoes the nonce in its verdict so +// services can key a dedup cache on it. +package reqsig + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "strconv" + "strings" + "time" +) + +const ( + // Domain is the envelope domain-separation prefix. + Domain = "pilot-req-v1" + + // DefaultMaxSkew is the default freshness window for envelope + // timestamps. Deliberately generous: the fleet includes embedded + // devices and NAT'd VMs with real clock skew, and replay within the + // window is bounded by the consumer's nonce dedup. + DefaultMaxSkew = 300 * time.Second + + // NonceLen is the hex length of an envelope nonce (8 random bytes). + NonceLen = 16 + + fieldCount = 6 +) + +// Envelope is a parsed request-signature envelope. +type Envelope struct { + Network uint16 + Node uint32 + Timestamp int64 // unix seconds + Nonce string // 16 lowercase hex chars + BodyHash string // 64 lowercase hex chars (sha256 of body) + Audience string // [a-z0-9.-]{1,64} +} + +// HashBody returns the canonical body-hash field for a request body. +func HashBody(body []byte) string { + sum := sha256.Sum256(body) + return hex.EncodeToString(sum[:]) +} + +// NewNonce returns a fresh random envelope nonce. +func NewNonce() (string, error) { + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + return "", fmt.Errorf("reqsig: nonce: %w", err) + } + return hex.EncodeToString(b[:]), nil +} + +// Canonical validates the envelope fields and returns the canonical string. +func (e Envelope) Canonical() (string, error) { + if e.Timestamp < 0 { + return "", fmt.Errorf("reqsig: negative timestamp") + } + if !isLowerHex(e.Nonce, NonceLen) { + return "", fmt.Errorf("reqsig: nonce must be %d lowercase hex chars", NonceLen) + } + if !isLowerHex(e.BodyHash, 64) { + return "", fmt.Errorf("reqsig: body hash must be 64 lowercase hex chars") + } + if err := checkAudience(e.Audience); err != nil { + return "", err + } + return Domain + "|" + + addrHex(e.Network, e.Node) + "|" + + strconv.FormatInt(e.Timestamp, 10) + "|" + + e.Nonce + "|" + + e.BodyHash + "|" + + e.Audience, nil +} + +// Parse parses and validates a canonical envelope string. +func Parse(s string) (Envelope, error) { + parts := strings.Split(s, "|") + if len(parts) != fieldCount { + return Envelope{}, fmt.Errorf("reqsig: want %d fields, got %d", fieldCount, len(parts)) + } + if parts[0] != Domain { + return Envelope{}, fmt.Errorf("reqsig: bad domain %q", parts[0]) + } + network, node, err := parseAddrHex(parts[1]) + if err != nil { + return Envelope{}, err + } + ts, err := parseCanonicalDecimal(parts[2]) + if err != nil { + return Envelope{}, fmt.Errorf("reqsig: timestamp: %w", err) + } + e := Envelope{ + Network: network, + Node: node, + Timestamp: ts, + Nonce: parts[3], + BodyHash: parts[4], + Audience: parts[5], + } + // Round-trip through Canonical so every field-level rule is enforced + // on the parse path too, and the accepted string is bit-identical to + // what Canonical would produce. + canon, err := e.Canonical() + if err != nil { + return Envelope{}, err + } + if canon != s { + return Envelope{}, fmt.Errorf("reqsig: non-canonical encoding") + } + return e, nil +} + +// Sign validates the envelope, builds its canonical form, and signs it. +// Returns the canonical string and the base64 signature. +func Sign(priv ed25519.PrivateKey, e Envelope) (canonical, sigB64 string, err error) { + canonical, err = e.Canonical() + if err != nil { + return "", "", err + } + sig := ed25519.Sign(priv, []byte(canonical)) + return canonical, base64.StdEncoding.EncodeToString(sig), nil +} + +// Verify checks sigB64 over the canonical envelope string with pub and +// returns the parsed envelope. It performs NO freshness check — callers +// combine it with CheckFresh. +func Verify(pub ed25519.PublicKey, canonical, sigB64 string) (Envelope, error) { + e, err := Parse(canonical) + if err != nil { + return Envelope{}, err + } + if len(pub) != ed25519.PublicKeySize { + return Envelope{}, fmt.Errorf("reqsig: bad public key size %d", len(pub)) + } + sig, err := base64.StdEncoding.DecodeString(sigB64) + if err != nil { + return Envelope{}, fmt.Errorf("reqsig: signature not base64: %w", err) + } + if !ed25519.Verify(pub, []byte(canonical), sig) { + return Envelope{}, fmt.Errorf("reqsig: signature verification failed") + } + return e, nil +} + +// CheckFresh returns an error when the envelope timestamp is outside the +// [now-maxSkew, now+maxSkew] window. maxSkew <= 0 selects DefaultMaxSkew. +func CheckFresh(e Envelope, now time.Time, maxSkew time.Duration) error { + if maxSkew <= 0 { + maxSkew = DefaultMaxSkew + } + d := now.Unix() - e.Timestamp + if d < 0 { + d = -d + } + if time.Duration(d)*time.Second > maxSkew { + return fmt.Errorf("reqsig: envelope timestamp outside ±%s window", maxSkew) + } + return nil +} + +// --------------------------------------------------------------------------- +// field helpers +// --------------------------------------------------------------------------- + +func addrHex(network uint16, node uint32) string { + return fmt.Sprintf("%04x%08x", network, node) +} + +func parseAddrHex(s string) (uint16, uint32, error) { + if !isLowerHex(s, 12) { + return 0, 0, fmt.Errorf("reqsig: address must be 12 lowercase hex chars") + } + network, err := strconv.ParseUint(s[:4], 16, 16) + if err != nil { + return 0, 0, fmt.Errorf("reqsig: address network: %w", err) + } + node, err := strconv.ParseUint(s[4:], 16, 32) + if err != nil { + return 0, 0, fmt.Errorf("reqsig: address node: %w", err) + } + return uint16(network), uint32(node), nil +} + +func isLowerHex(s string, n int) bool { + if len(s) != n { + return false + } + for i := 0; i < len(s); i++ { + c := s[i] + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + return false + } + } + return true +} + +// parseCanonicalDecimal accepts only the canonical decimal form: digits, +// no sign, no leading zeros (except "0" itself). +func parseCanonicalDecimal(s string) (int64, error) { + if s == "" { + return 0, fmt.Errorf("empty") + } + if len(s) > 1 && s[0] == '0' { + return 0, fmt.Errorf("leading zero") + } + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return 0, fmt.Errorf("non-digit") + } + } + return strconv.ParseInt(s, 10, 64) +} + +func checkAudience(s string) error { + if len(s) == 0 || len(s) > 64 { + return fmt.Errorf("reqsig: audience must be 1-64 chars") + } + for i := 0; i < len(s); i++ { + c := s[i] + if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '.' && c != '-' { + return fmt.Errorf("reqsig: audience must match [a-z0-9.-]") + } + } + return nil +} diff --git a/reqsig/verdict.go b/reqsig/verdict.go new file mode 100644 index 0000000..44a6174 --- /dev/null +++ b/reqsig/verdict.go @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package reqsig + +import ( + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "strconv" + "strings" +) + +// Verdict is the registry's signed answer to a verification request. It +// binds the verified envelope (by hash) to the registry's assessment so a +// consumer can cache the verdict, forward it as proof, or check it offline +// without trusting the transport it arrived over. +// +// Canonical form: +// +// pilot-verdict-v1|envhash|aaaaaaaaaaaa|v|o|m|last_seen|keygen|verified_at +// +// envhash 64 lowercase hex, sha256 of the canonical envelope string +// aaaa… 12 lowercase hex, the address the verdict is about +// v/o/m single chars '0'/'1': valid, online, network_member +// last_seen canonical decimal unix seconds (0 when unknown/invalid) +// keygen canonical decimal key generation (rotate count; 0 unknown) +// verified_at canonical decimal unix seconds of the verification +// +// The verdict-issuer keyring follows the badgeverify pattern: kid→pubkey +// entries baked at build time, overridable via +// +// -ldflags "-X github.com/pilot-protocol/common/reqsig.verdictKeyringB64=vfy-v1=" +// +// Unknown kid or empty keyring fails closed. +type Verdict struct { + EnvHash string // sha256 hex of the canonical envelope + Network uint16 + Node uint32 + Valid bool + Online bool + NetworkMember bool + LastSeenUnix int64 + KeyGeneration int64 + VerifiedAt int64 // unix seconds +} + +// VerdictDomain is the verdict domain-separation prefix. +const VerdictDomain = "pilot-verdict-v1" + +const verdictFieldCount = 9 + +// verdictKeyringB64 holds comma-separated kid=base64pubkey entries for +// verdict issuers. Empty by default: offline verdict verification is +// unavailable unless a keyring is baked at build time or supplied via +// VerifyVerdictWithKey. Fail-closed, same as badgeverify. +var verdictKeyringB64 = "" + +// HashEnvelope returns the canonical envelope-hash field for a canonical +// envelope string. +func HashEnvelope(canonical string) string { + sum := sha256.Sum256([]byte(canonical)) + return hex.EncodeToString(sum[:]) +} + +// Canonical validates the verdict fields and returns the canonical string. +func (v Verdict) Canonical() (string, error) { + if !isLowerHex(v.EnvHash, 64) { + return "", fmt.Errorf("reqsig: verdict env hash must be 64 lowercase hex chars") + } + if v.LastSeenUnix < 0 || v.KeyGeneration < 0 || v.VerifiedAt < 0 { + return "", fmt.Errorf("reqsig: verdict fields must be non-negative") + } + return VerdictDomain + "|" + + v.EnvHash + "|" + + addrHex(v.Network, v.Node) + "|" + + boolChar(v.Valid) + "|" + + boolChar(v.Online) + "|" + + boolChar(v.NetworkMember) + "|" + + strconv.FormatInt(v.LastSeenUnix, 10) + "|" + + strconv.FormatInt(v.KeyGeneration, 10) + "|" + + strconv.FormatInt(v.VerifiedAt, 10), nil +} + +// ParseVerdict parses and validates a canonical verdict string. +func ParseVerdict(s string) (Verdict, error) { + parts := strings.Split(s, "|") + if len(parts) != verdictFieldCount { + return Verdict{}, fmt.Errorf("reqsig: verdict wants %d fields, got %d", verdictFieldCount, len(parts)) + } + if parts[0] != VerdictDomain { + return Verdict{}, fmt.Errorf("reqsig: bad verdict domain %q", parts[0]) + } + network, node, err := parseAddrHex(parts[2]) + if err != nil { + return Verdict{}, err + } + valid, err := parseBoolChar(parts[3]) + if err != nil { + return Verdict{}, err + } + online, err := parseBoolChar(parts[4]) + if err != nil { + return Verdict{}, err + } + member, err := parseBoolChar(parts[5]) + if err != nil { + return Verdict{}, err + } + lastSeen, err := parseCanonicalDecimal(parts[6]) + if err != nil { + return Verdict{}, fmt.Errorf("reqsig: verdict last_seen: %w", err) + } + keyGen, err := parseCanonicalDecimal(parts[7]) + if err != nil { + return Verdict{}, fmt.Errorf("reqsig: verdict key generation: %w", err) + } + verifiedAt, err := parseCanonicalDecimal(parts[8]) + if err != nil { + return Verdict{}, fmt.Errorf("reqsig: verdict verified_at: %w", err) + } + v := Verdict{ + EnvHash: parts[1], + Network: network, + Node: node, + Valid: valid, + Online: online, + NetworkMember: member, + LastSeenUnix: lastSeen, + KeyGeneration: keyGen, + VerifiedAt: verifiedAt, + } + canon, err := v.Canonical() + if err != nil { + return Verdict{}, err + } + if canon != s { + return Verdict{}, fmt.Errorf("reqsig: non-canonical verdict encoding") + } + return v, nil +} + +// SignVerdict validates the verdict, builds its canonical form, and signs it. +func SignVerdict(priv ed25519.PrivateKey, v Verdict) (canonical, sigB64 string, err error) { + canonical, err = v.Canonical() + if err != nil { + return "", "", err + } + sig := ed25519.Sign(priv, []byte(canonical)) + return canonical, base64.StdEncoding.EncodeToString(sig), nil +} + +// VerifyVerdict verifies a verdict signature using the build-embedded +// keyring, selecting the issuer key by kid. Fails closed on unknown kid. +func VerifyVerdict(kid, canonical, sigB64 string) (Verdict, error) { + pub := verdictKeyFor(kid) + if pub == nil { + return Verdict{}, fmt.Errorf("reqsig: no verdict key for kid %q", kid) + } + return VerifyVerdictWithKey(pub, canonical, sigB64) +} + +// VerifyVerdictWithKey verifies a verdict signature with an explicit issuer +// public key (e.g. one fetched from the registry's verify-keys endpoint). +func VerifyVerdictWithKey(pub ed25519.PublicKey, canonical, sigB64 string) (Verdict, error) { + v, err := ParseVerdict(canonical) + if err != nil { + return Verdict{}, err + } + if len(pub) != ed25519.PublicKeySize { + return Verdict{}, fmt.Errorf("reqsig: bad verdict public key size %d", len(pub)) + } + sig, err := base64.StdEncoding.DecodeString(sigB64) + if err != nil { + return Verdict{}, fmt.Errorf("reqsig: verdict signature not base64: %w", err) + } + if !ed25519.Verify(pub, []byte(canonical), sig) { + return Verdict{}, fmt.Errorf("reqsig: verdict signature verification failed") + } + return v, nil +} + +func verdictKeyFor(kid string) ed25519.PublicKey { + if kid == "" || verdictKeyringB64 == "" { + return nil + } + for _, entry := range strings.Split(verdictKeyringB64, ",") { + k, v, ok := strings.Cut(strings.TrimSpace(entry), "=") + if !ok || k != kid { + continue + } + raw, err := base64.StdEncoding.DecodeString(v) + if err != nil || len(raw) != ed25519.PublicKeySize { + return nil + } + if isAllZero(raw) { + return nil + } + return ed25519.PublicKey(raw) + } + return nil +} + +func isAllZero(b []byte) bool { + var acc byte + for _, c := range b { + acc |= c + } + return acc == 0 +} + +func boolChar(b bool) string { + if b { + return "1" + } + return "0" +} + +func parseBoolChar(s string) (bool, error) { + switch s { + case "0": + return false, nil + case "1": + return true, nil + } + return false, fmt.Errorf("reqsig: bool field must be '0' or '1'") +} diff --git a/reqsig/zz_errpaths_test.go b/reqsig/zz_errpaths_test.go new file mode 100644 index 0000000..3e3d383 --- /dev/null +++ b/reqsig/zz_errpaths_test.go @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package reqsig + +import ( + "crypto/ed25519" + "crypto/rand" + "strings" + "testing" +) + +func TestSignRejectsInvalidEnvelope(t *testing.T) { + _, priv := testKey(t) + e := testEnvelope() + e.Nonce = "not-hex" + if _, _, err := Sign(priv, e); err == nil { + t.Fatal("expected Sign to reject an invalid envelope") + } +} + +func TestVerifyRejectsBadKeyAndSignatureEncoding(t *testing.T) { + pub, priv := testKey(t) + canon, sig, err := Sign(priv, testEnvelope()) + if err != nil { + t.Fatal(err) + } + if _, err := Verify(pub[:16], canon, sig); err == nil { + t.Fatal("expected error for truncated public key") + } + if _, err := Verify(pub, canon, "!!!not-base64!!!"); err == nil { + t.Fatal("expected error for non-base64 signature") + } +} + +func TestCanonicalRejectsNegativeTimestamp(t *testing.T) { + e := testEnvelope() + e.Timestamp = -1 + if _, err := e.Canonical(); err == nil { + t.Fatal("expected error for negative timestamp") + } +} + +func TestParseRejectsEmptyTimestampField(t *testing.T) { + base, err := testEnvelope().Canonical() + if err != nil { + t.Fatal(err) + } + in := strings.Replace(base, "|1780000000|", "||", 1) + if _, err := Parse(in); err == nil { + t.Fatal("expected error for empty timestamp field") + } +} + +func TestVerdictCanonicalRejectsBadFields(t *testing.T) { + v := testVerdict() + v.EnvHash = "beef" + if _, err := v.Canonical(); err == nil { + t.Fatal("expected error for short env hash") + } + v = testVerdict() + v.LastSeenUnix = -1 + if _, err := v.Canonical(); err == nil { + t.Fatal("expected error for negative last_seen") + } + v = testVerdict() + v.KeyGeneration = -2 + if _, err := v.Canonical(); err == nil { + t.Fatal("expected error for negative key generation") + } + v = testVerdict() + v.VerifiedAt = -3 + if _, err := v.Canonical(); err == nil { + t.Fatal("expected error for negative verified_at") + } +} + +func TestSignVerdictRejectsInvalidVerdict(t *testing.T) { + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + v := testVerdict() + v.EnvHash = "nope" + if _, _, err := SignVerdict(priv, v); err == nil { + t.Fatal("expected SignVerdict to reject an invalid verdict") + } +} + +func TestParseVerdictFieldErrors(t *testing.T) { + base, err := testVerdict().Canonical() + if err != nil { + t.Fatal(err) + } + cases := map[string]string{ + "bad address": strings.Replace(base, "0001abcd1234", "0001ABCD1234", 1), + "bad last_seen": strings.Replace(base, "|1779999980|", "|017|", 1), + "bad keygen": strings.Replace(base, "|3|", "|x|", 1), + "bad verified_at": strings.Replace(base, "|1780000000", "|-1", 1), + "bad online bool": strings.Replace(base, "|1|1|0|", "|1|yes|0|", 1), + "bad member bool": strings.Replace(base, "|1|1|0|", "|1|1|2|", 1), + } + for name, in := range cases { + if in == base { + t.Fatalf("%s: replacement did not apply", name) + } + if _, err := ParseVerdict(in); err == nil { + t.Errorf("%s: expected parse error", name) + } + } +} + +func TestVerifyVerdictWithKeyRejectsBadInputs(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + canon, sig, err := SignVerdict(priv, testVerdict()) + if err != nil { + t.Fatal(err) + } + if _, err := VerifyVerdictWithKey(pub[:8], canon, sig); err == nil { + t.Fatal("expected error for truncated verdict key") + } + if _, err := VerifyVerdictWithKey(pub, canon, "%%%"); err == nil { + t.Fatal("expected error for non-base64 verdict signature") + } + if _, err := VerifyVerdictWithKey(pub, "garbage", sig); err == nil { + t.Fatal("expected error for unparseable verdict") + } +} + +func TestVerdictKeyringMalformedEntries(t *testing.T) { + saved := verdictKeyringB64 + defer func() { verdictKeyringB64 = saved }() + + verdictKeyringB64 = "no-equals-sign,vfy-bad=!!!,vfy-short=YWJj" + for _, kid := range []string{"no-equals-sign", "vfy-bad", "vfy-short"} { + if k := verdictKeyFor(kid); k != nil { + t.Errorf("kid %q: expected nil key from malformed entry", kid) + } + } +} diff --git a/reqsig/zz_reqsig_test.go b/reqsig/zz_reqsig_test.go new file mode 100644 index 0000000..c989dda --- /dev/null +++ b/reqsig/zz_reqsig_test.go @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package reqsig + +import ( + "crypto/ed25519" + "crypto/rand" + "strings" + "testing" + "time" +) + +func testKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) { + t.Helper() + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + return pub, priv +} + +func testEnvelope() Envelope { + return Envelope{ + Network: 1, + Node: 0xABCD1234, + Timestamp: 1780000000, + Nonce: "0123456789abcdef", + BodyHash: HashBody([]byte("hello")), + Audience: "svc.example.io", + } +} + +func TestCanonicalRoundTrip(t *testing.T) { + e := testEnvelope() + canon, err := e.Canonical() + if err != nil { + t.Fatal(err) + } + want := "pilot-req-v1|0001abcd1234|1780000000|0123456789abcdef|" + + HashBody([]byte("hello")) + "|svc.example.io" + if canon != want { + t.Fatalf("canonical mismatch:\n got %q\nwant %q", canon, want) + } + got, err := Parse(canon) + if err != nil { + t.Fatal(err) + } + if got != e { + t.Fatalf("round trip mismatch: %+v vs %+v", got, e) + } +} + +func TestSignVerify(t *testing.T) { + pub, priv := testKey(t) + canon, sig, err := Sign(priv, testEnvelope()) + if err != nil { + t.Fatal(err) + } + e, err := Verify(pub, canon, sig) + if err != nil { + t.Fatal(err) + } + if e.Node != 0xABCD1234 || e.Audience != "svc.example.io" { + t.Fatalf("bad parsed envelope: %+v", e) + } +} + +func TestVerifyRejectsWrongKey(t *testing.T) { + _, priv := testKey(t) + otherPub, _ := testKey(t) + canon, sig, err := Sign(priv, testEnvelope()) + if err != nil { + t.Fatal(err) + } + if _, err := Verify(otherPub, canon, sig); err == nil { + t.Fatal("expected verification failure with wrong key") + } +} + +func TestVerifyRejectsTamperedField(t *testing.T) { + pub, priv := testKey(t) + canon, sig, err := Sign(priv, testEnvelope()) + if err != nil { + t.Fatal(err) + } + tampered := strings.Replace(canon, "svc.example.io", "svc.evil.io", 1) + if _, err := Verify(pub, tampered, sig); err == nil { + t.Fatal("expected verification failure on tampered audience") + } +} + +func TestParseRejectsBadInputs(t *testing.T) { + e := testEnvelope() + base, err := e.Canonical() + if err != nil { + t.Fatal(err) + } + cases := map[string]string{ + "wrong domain": strings.Replace(base, "pilot-req-v1", "pilot-req-v2", 1), + "short address": strings.Replace(base, "0001abcd1234", "001abcd1234", 1), + "upper hex address": strings.Replace(base, "0001abcd1234", "0001ABCD1234", 1), + "leading zero ts": strings.Replace(base, "|1780000000|", "|01780000000|", 1), + "negative ts": strings.Replace(base, "|1780000000|", "|-178|", 1), + "short nonce": strings.Replace(base, "0123456789abcdef", "0123456789abcde", 1), + "empty audience": strings.TrimSuffix(base, "svc.example.io"), + "audience bad chars": strings.Replace(base, "svc.example.io", "svc|example", 1), + "audience upper": strings.Replace(base, "svc.example.io", "SVC.example.io", 1), + "extra field": base + "|x", + "missing field": strings.TrimSuffix(base, "|svc.example.io"), + "empty": "", + "raw protocol string": "handshake:12345:67890", + } + for name, in := range cases { + if _, err := Parse(in); err == nil { + t.Errorf("%s: expected parse error for %q", name, in) + } + } +} + +func TestCanonicalRejectsBadFields(t *testing.T) { + cases := map[string]Envelope{ + "bad nonce": {Nonce: "xyz", BodyHash: HashBody(nil), Audience: "a"}, + "bad body hash": {Nonce: "0123456789abcdef", BodyHash: "beef", Audience: "a"}, + "long audience": {Nonce: "0123456789abcdef", BodyHash: HashBody(nil), Audience: strings.Repeat("a", 65)}, + "pipe audience": {Nonce: "0123456789abcdef", BodyHash: HashBody(nil), Audience: "a|b"}, + } + for name, e := range cases { + if _, err := e.Canonical(); err == nil { + t.Errorf("%s: expected error", name) + } + } +} + +func TestCheckFresh(t *testing.T) { + now := time.Unix(1780000000, 0) + e := testEnvelope() + + if err := CheckFresh(e, now, 0); err != nil { + t.Fatalf("exact time should be fresh: %v", err) + } + if err := CheckFresh(e, now.Add(299*time.Second), 0); err != nil { + t.Fatalf("inside default window: %v", err) + } + if err := CheckFresh(e, now.Add(301*time.Second), 0); err == nil { + t.Fatal("expected stale past default window") + } + if err := CheckFresh(e, now.Add(-301*time.Second), 0); err == nil { + t.Fatal("expected stale for future-dated envelope") + } + if err := CheckFresh(e, now.Add(30*time.Second), 10*time.Second); err == nil { + t.Fatal("expected stale with tight custom window") + } +} + +func TestNewNonce(t *testing.T) { + a, err := NewNonce() + if err != nil { + t.Fatal(err) + } + b, err := NewNonce() + if err != nil { + t.Fatal(err) + } + if !isLowerHex(a, NonceLen) || !isLowerHex(b, NonceLen) { + t.Fatalf("nonces not canonical hex: %q %q", a, b) + } + if a == b { + t.Fatal("nonces must differ") + } +} diff --git a/reqsig/zz_verdict_test.go b/reqsig/zz_verdict_test.go new file mode 100644 index 0000000..0712b67 --- /dev/null +++ b/reqsig/zz_verdict_test.go @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package reqsig + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "strings" + "testing" +) + +func testVerdict() Verdict { + canon, _ := testEnvelope().Canonical() + return Verdict{ + EnvHash: HashEnvelope(canon), + Network: 1, + Node: 0xABCD1234, + Valid: true, + Online: true, + NetworkMember: false, + LastSeenUnix: 1779999980, + KeyGeneration: 3, + VerifiedAt: 1780000000, + } +} + +func TestVerdictCanonicalRoundTrip(t *testing.T) { + v := testVerdict() + canon, err := v.Canonical() + if err != nil { + t.Fatal(err) + } + got, err := ParseVerdict(canon) + if err != nil { + t.Fatal(err) + } + if got != v { + t.Fatalf("round trip mismatch:\n got %+v\nwant %+v", got, v) + } +} + +func TestVerdictSignVerifyWithKey(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + canon, sig, err := SignVerdict(priv, testVerdict()) + if err != nil { + t.Fatal(err) + } + v, err := VerifyVerdictWithKey(pub, canon, sig) + if err != nil { + t.Fatal(err) + } + if !v.Valid || !v.Online || v.KeyGeneration != 3 { + t.Fatalf("bad parsed verdict: %+v", v) + } +} + +func TestVerdictVerifyRejectsTamper(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + canon, sig, err := SignVerdict(priv, testVerdict()) + if err != nil { + t.Fatal(err) + } + // Flip valid → invalid and online bits; both must break the signature. + tampered := strings.Replace(canon, "|1|1|0|", "|0|1|0|", 1) + if tampered == canon { + t.Fatal("test setup: replacement did not apply") + } + if _, err := VerifyVerdictWithKey(pub, tampered, sig); err == nil { + t.Fatal("expected verification failure on tampered verdict") + } +} + +func TestVerdictKeyringFailClosed(t *testing.T) { + // Default build has an empty keyring: every kid must fail. + if _, err := VerifyVerdict("vfy-v1", "x", "y"); err == nil { + t.Fatal("expected fail-closed with empty keyring") + } + + // Populated keyring: known kid verifies, unknown kid fails, + // all-zero key entries are rejected. + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + saved := verdictKeyringB64 + defer func() { verdictKeyringB64 = saved }() + zero := base64.StdEncoding.EncodeToString(make([]byte, ed25519.PublicKeySize)) + verdictKeyringB64 = "vfy-v1=" + base64.StdEncoding.EncodeToString(pub) + ",vfy-zero=" + zero + + canon, sig, err := SignVerdict(priv, testVerdict()) + if err != nil { + t.Fatal(err) + } + if _, err := VerifyVerdict("vfy-v1", canon, sig); err != nil { + t.Fatalf("known kid should verify: %v", err) + } + if _, err := VerifyVerdict("vfy-v2", canon, sig); err == nil { + t.Fatal("unknown kid must fail closed") + } + if _, err := VerifyVerdict("vfy-zero", canon, sig); err == nil { + t.Fatal("all-zero key must be rejected") + } +} + +func TestParseVerdictRejectsBadInputs(t *testing.T) { + base, err := testVerdict().Canonical() + if err != nil { + t.Fatal(err) + } + cases := map[string]string{ + "wrong domain": strings.Replace(base, VerdictDomain, "pilot-verdict-v2", 1), + "bad bool": strings.Replace(base, "|1|1|0|", "|2|1|0|", 1), + "extra field": base + "|x", + "empty": "", + } + for name, in := range cases { + if _, err := ParseVerdict(in); err == nil { + t.Errorf("%s: expected parse error", name) + } + } +}