Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions driver/driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion driver/ipc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 —
Expand Down Expand Up @@ -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)
Expand Down
162 changes: 162 additions & 0 deletions driver/zz_driver_envelope_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
43 changes: 43 additions & 0 deletions driver/zz_envelope_bounds_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
5 changes: 5 additions & 0 deletions ipcutil/ipcutil.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading