diff --git a/badgeverify/badgeverify.go b/badgeverify/badgeverify.go new file mode 100644 index 0000000..ce6a5d6 --- /dev/null +++ b/badgeverify/badgeverify.go @@ -0,0 +1,282 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Package badgeverify defines the canonical wire format for a Pilot +// "verified address" badge and an offline verifier for it. +// +// A badge is a detached Ed25519-signed credential, bound to a single +// node_id, that asserts the address was verified through an external +// identity provider (GitHub / Google / WorkOS). It deliberately carries +// NO raw external identity (no GitHub login, no email) — only which +// provider vouched and when. An app certifies a badge is genuine by +// verifying the issuer signature against a pinned public key, entirely +// offline: no network round-trip and no trust in the registry that +// served the badge. +// +// THE BINDING RULE (read this before using Verify): +// +// A badge is public — anyone can fetch any node's badge. A badge is only +// meaningful when checked against the node_id that the secure/handshake +// layer has *cryptographically authenticated* for the connection. Always +// confirm Badge.NodeID equals the authenticated peer's node_id, or use +// VerifyForNode which does it for you. Verifying the signature alone lets +// an attacker replay another node's valid badge. +package badgeverify + +import ( + "crypto/ed25519" + "encoding/base64" + "errors" + "fmt" + "strconv" + "strings" + "time" +) + +// Version is the canonical-format version tag carried in every badge. A +// future incompatible format change bumps this and adds a parser branch. +const Version = "v1" + +// Domain-separation prefixes. Every credential type the issuer signs gets +// a distinct, unspoofable prefix as the first field of its canonical +// signed bytes, so a signature over one type can NEVER validate as +// another (a badge cannot be replayed as a recovery authorization). Each +// parser rejects a wrong prefix. This is the primary cross-statement +// defense; the two-key split (badge kid vs recovery kid) is layered on top. +const ( + prefixBadge = "pilotbadge" + prefixEnroll = "pilotenroll" + prefixRecover = "pilotrecover" +) + +// keyringB64 maps key-id -> base64 Ed25519 public key, encoded as +// comma-separated "kid=base64" entries. It holds the ONLINE issuer keys +// that sign badges and enrollments. A statement's Kid selects which key +// verifies it, so a key can be rotated without invalidating already-issued +// credentials: ship the new key alongside the old, issue under the new +// kid, retire the old once its credentials expire. +// +// Overridable at build time (the compiled-in default is a placeholder — +// it MUST be replaced at release): +// +// -ldflags "-X github.com/pilot-protocol/common/badgeverify.keyringB64=v1=" +var keyringB64 = "v1=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + +// recoveryKeyringB64 is a SEPARATE pinned keyring holding only the COLD +// recovery-authority keys, which sign nothing but recovery authorizations. +// Recovery statements verify against this keyring exclusively — so even a +// total compromise of the online badge keyring above cannot forge a +// recovery (and thus cannot seize any address). Keep the matching private +// key offline/air-gapped. +// +// -ldflags "-X github.com/pilot-protocol/common/badgeverify.recoveryKeyringB64=rec-v1=" +var recoveryKeyringB64 = "rec-v1=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + +var ( + // ErrNoKey is returned when no pinned key matches the badge's kid (or + // the keyring is malformed). Fail-closed: with no trust anchor, + // nothing verifies. + ErrNoKey = errors.New("badgeverify: no pinned issuer key for badge kid") + // ErrBadSignature is returned when the signature does not verify. + ErrBadSignature = errors.New("badgeverify: badge signature verification failed") + // ErrMalformed is returned when the badge string is not well-formed. + ErrMalformed = errors.New("badgeverify: malformed badge") + // ErrExpired is returned when the badge's exp is in the past. + ErrExpired = errors.New("badgeverify: badge expired") + // ErrNodeMismatch is returned by VerifyForNode when the badge is for + // a different node than the authenticated peer. + ErrNodeMismatch = errors.New("badgeverify: badge node_id does not match peer") +) + +// Badge is the decoded, structured form of a verified-address credential. +type Badge struct { + NodeID uint32 // the address this badge is bound to + Provider string // identity authority that vouched: "github" | "google" | "workos" + VerifiedAt int64 // unix seconds, coarsened to day granularity at issue time + Exp int64 // unix seconds; 0 means no expiry + Kid string // issuer key-id that signed this badge (selects the verify key) + Subject string // OPTIONAL public label (Tier 1); empty for Tier 0 (provider-only) +} + +// noColon rejects values that would break the ':'-delimited canonical +// encoding. provider/kid/subject are constrained; everything else is +// numeric. +func noColon(field, v string) error { + if strings.ContainsRune(v, ':') { + return fmt.Errorf("%w: %s must not contain ':'", ErrMalformed, field) + } + return nil +} + +// Canonical returns the exact byte string that is signed and that travels +// on the wire as the "badge" field. The issuer signs these bytes; the +// verifier checks the signature over the bytes it received and only then +// trusts the parsed fields. +// +// Layout: pilotbadge:v1:::::: +func Canonical(b Badge) (string, error) { + if b.Provider == "" { + return "", fmt.Errorf("%w: provider is required", ErrMalformed) + } + if b.Kid == "" { + return "", fmt.Errorf("%w: kid is required", ErrMalformed) + } + for _, c := range []struct{ name, v string }{ + {"provider", b.Provider}, {"kid", b.Kid}, {"subject", b.Subject}, + } { + if err := noColon(c.name, c.v); err != nil { + return "", err + } + } + return fmt.Sprintf("%s:%s:%d:%s:%d:%d:%s:%s", + prefixBadge, Version, b.NodeID, b.Provider, b.VerifiedAt, b.Exp, b.Kid, b.Subject), nil +} + +// Parse decodes a canonical badge string WITHOUT verifying its signature. +// Use Verify/VerifyForNode for anything trust-bearing. +func Parse(s string) (Badge, error) { + parts := strings.Split(s, ":") + if len(parts) != 8 { + return Badge{}, fmt.Errorf("%w: want 8 fields, got %d", ErrMalformed, len(parts)) + } + if parts[0] != prefixBadge { + return Badge{}, fmt.Errorf("%w: bad prefix %q", ErrMalformed, parts[0]) + } + if parts[1] != Version { + return Badge{}, fmt.Errorf("%w: unsupported version %q", ErrMalformed, parts[1]) + } + nodeID, err := strconv.ParseUint(parts[2], 10, 32) + if err != nil { + return Badge{}, fmt.Errorf("%w: node_id: %v", ErrMalformed, err) + } + verifiedAt, err := strconv.ParseInt(parts[4], 10, 64) + if err != nil { + return Badge{}, fmt.Errorf("%w: verified_at: %v", ErrMalformed, err) + } + exp, err := strconv.ParseInt(parts[5], 10, 64) + if err != nil { + return Badge{}, fmt.Errorf("%w: exp: %v", ErrMalformed, err) + } + if parts[3] == "" { + return Badge{}, fmt.Errorf("%w: provider is required", ErrMalformed) + } + if parts[6] == "" { + return Badge{}, fmt.Errorf("%w: kid is required", ErrMalformed) + } + return Badge{ + NodeID: uint32(nodeID), + Provider: parts[3], + VerifiedAt: verifiedAt, + Exp: exp, + Kid: parts[6], + Subject: parts[7], + }, nil +} + +// isAllZero reports whether b is entirely zero bytes. The compiled-in +// placeholder key is all zeros; an all-zero Ed25519 public key is also a +// low-order point that can verify crafted signatures, so we treat it as +// "no key" and fail closed. This guards against shipping a binary whose +// -ldflags issuer-key override was forgotten. +func isAllZero(b []byte) bool { + for _, x := range b { + if x != 0 { + return false + } + } + return true +} + +// lookupKey returns the pinned public key for kid within the given keyring +// string, or nil if absent/malformed/all-zero (fail-closed). +func lookupKey(keyring, kid string) ed25519.PublicKey { + for _, entry := range strings.Split(keyring, ",") { + entry = strings.TrimSpace(entry) + k, v, ok := strings.Cut(entry, "=") + if !ok || k != kid { + continue + } + raw, err := base64.StdEncoding.DecodeString(v) + if err != nil || len(raw) != ed25519.PublicKeySize || isAllZero(raw) { + return nil + } + return ed25519.PublicKey(raw) + } + return nil +} + +// keyFor selects an online badge/enrollment key; recoveryKeyFor selects a +// cold recovery-authority key. The two keyrings never overlap, which is +// what enforces the two-key split. +func keyFor(kid string) ed25519.PublicKey { return lookupKey(keyringB64, kid) } +func recoveryKeyFor(kid string) ed25519.PublicKey { return lookupKey(recoveryKeyringB64, kid) } + +// Verify parses badgeStr, then checks that sigB64 is a valid Ed25519 +// signature over the EXACT received bytes, made with the pinned issuer +// key named by the badge's kid, and that the badge has not expired. +// +// It does NOT check the binding to a node — see the package doc and +// VerifyForNode. Callers that already hold the authenticated peer node_id +// should prefer VerifyForNode. +func Verify(badgeStr, sigB64 string) (Badge, error) { + return verifyAt(badgeStr, sigB64, time.Now()) +} + +// VerifyForNode is Verify plus the binding rule: it additionally requires +// the badge to be bound to peerNodeID (the node_id the secure/handshake +// layer authenticated for this connection). This is the safe entry point +// for apps. +func VerifyForNode(badgeStr, sigB64 string, peerNodeID uint32) (Badge, error) { + b, err := verifyAt(badgeStr, sigB64, time.Now()) + if err != nil { + return b, err + } + if b.NodeID != peerNodeID { + return b, fmt.Errorf("%w: badge=%d peer=%d", ErrNodeMismatch, b.NodeID, peerNodeID) + } + return b, nil +} + +func verifyAt(badgeStr, sigB64 string, now time.Time) (Badge, error) { + b, err := Parse(badgeStr) + if err != nil { + return Badge{}, err + } + if err := verifyDetached(badgeStr, sigB64, b.Kid); err != nil { + return b, err + } + if b.Exp != 0 && now.Unix() > b.Exp { + return b, fmt.Errorf("%w: exp=%d now=%d", ErrExpired, b.Exp, now.Unix()) + } + return b, nil +} + +// verifyDetached is the signature-verification path for badges and +// enrollments (online keyring). verifyDetachedRecovery is the equivalent +// for recovery authorizations (cold keyring). Both go through verifyKeyed, +// the single auditable place where fail-closed key selection and the +// length/verify checks live. signed is the exact canonical string. +func verifyDetached(signed, sigB64, kid string) error { + return verifyKeyed(keyFor, signed, sigB64, kid) +} + +func verifyDetachedRecovery(signed, sigB64, kid string) error { + return verifyKeyed(recoveryKeyFor, signed, sigB64, kid) +} + +func verifyKeyed(lookup func(string) ed25519.PublicKey, signed, sigB64, kid string) error { + pk := lookup(kid) + if pk == nil { + return fmt.Errorf("%w: kid=%q", ErrNoKey, kid) + } + sig, err := base64.StdEncoding.DecodeString(sigB64) + if err != nil { + return fmt.Errorf("%w: signature is not valid base64", ErrBadSignature) + } + if len(sig) != ed25519.SignatureSize { + return fmt.Errorf("%w: wrong signature length %d (want %d)", ErrBadSignature, len(sig), ed25519.SignatureSize) + } + if !ed25519.Verify(pk, []byte(signed), sig) { + return ErrBadSignature + } + return nil +} diff --git a/badgeverify/badgeverify_test.go b/badgeverify/badgeverify_test.go new file mode 100644 index 0000000..2440e8d --- /dev/null +++ b/badgeverify/badgeverify_test.go @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package badgeverify + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "errors" + "testing" + "time" +) + +// newIssuer generates a throwaway issuer key and installs it in the +// keyring under the given kid for the duration of a test. +func newIssuer(t *testing.T, kid string) ed25519.PrivateKey { + t.Helper() + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate issuer key: %v", err) + } + orig := keyringB64 + keyringB64 = kid + "=" + base64.StdEncoding.EncodeToString(pub) + t.Cleanup(func() { keyringB64 = orig }) + return priv +} + +// sign produces the wire (badgeStr, sigB64) for a badge, the way the +// issuer sidecar will. +func sign(t *testing.T, priv ed25519.PrivateKey, b Badge) (string, string) { + t.Helper() + s, err := Canonical(b) + if err != nil { + t.Fatalf("canonical: %v", err) + } + sig := ed25519.Sign(priv, []byte(s)) + return s, base64.StdEncoding.EncodeToString(sig) +} + +func validBadge() Badge { + return Badge{NodeID: 0x1ABCD, Provider: "github", VerifiedAt: 1700000000, Exp: 0, Kid: "v1"} +} + +func TestCanonicalRoundTrip(t *testing.T) { + b := validBadge() + b.Subject = "acme-corp" + s, err := Canonical(b) + if err != nil { + t.Fatalf("canonical: %v", err) + } + got, err := Parse(s) + if err != nil { + t.Fatalf("parse: %v", err) + } + if got != b { + t.Fatalf("round-trip mismatch:\n got %+v\nwant %+v", got, b) + } +} + +func TestVerifyHappyPath(t *testing.T) { + priv := newIssuer(t, "v1") + s, sig := sign(t, priv, validBadge()) + + b, err := Verify(s, sig) + if err != nil { + t.Fatalf("verify: %v", err) + } + if b.NodeID != 0x1ABCD || b.Provider != "github" { + t.Fatalf("unexpected decoded badge: %+v", b) + } +} + +func TestVerifyForNodeBinding(t *testing.T) { + priv := newIssuer(t, "v1") + s, sig := sign(t, priv, validBadge()) + + if _, err := VerifyForNode(s, sig, 0x1ABCD); err != nil { + t.Fatalf("matching node should verify: %v", err) + } + // Replay onto a different node must fail even though the signature is valid. + if _, err := VerifyForNode(s, sig, 0x99999); !errors.Is(err, ErrNodeMismatch) { + t.Fatalf("want ErrNodeMismatch, got %v", err) + } +} + +func TestTamperedFieldFailsSignature(t *testing.T) { + priv := newIssuer(t, "v1") + _, sig := sign(t, priv, validBadge()) + + // Forge a higher-privilege provider by editing the signed string. + tampered := Badge{NodeID: 0x1ABCD, Provider: "workos", VerifiedAt: 1700000000, Kid: "v1"} + ts, _ := Canonical(tampered) + if _, err := Verify(ts, sig); !errors.Is(err, ErrBadSignature) { + t.Fatalf("tampered badge must fail signature, got %v", err) + } +} + +func TestUnknownKidFailsClosed(t *testing.T) { + priv := newIssuer(t, "v1") + b := validBadge() + b.Kid = "v2" // not in the keyring + s, sig := sign(t, priv, b) + if _, err := Verify(s, sig); !errors.Is(err, ErrNoKey) { + t.Fatalf("unknown kid must fail closed with ErrNoKey, got %v", err) + } +} + +func TestPlaceholderKeyFailsClosed(t *testing.T) { + // With the compiled-in all-zeros placeholder, verification must fail + // CLOSED with ErrNoKey — never attempting an ed25519.Verify against the + // low-order zero key. Guards against shipping without the -ldflags + // issuer-key override. + _, priv, _ := ed25519.GenerateKey(rand.Reader) + s, sig := sign(t, priv, validBadge()) + if _, err := Verify(s, sig); !errors.Is(err, ErrNoKey) { + t.Fatalf("placeholder keyring must fail closed with ErrNoKey, got %v", err) + } +} + +func TestExpiry(t *testing.T) { + priv := newIssuer(t, "v1") + b := validBadge() + b.Exp = 1700000000 // in the past relative to now + s, sig := sign(t, priv, b) + if _, err := verifyAt(s, sig, time.Unix(1700000001, 0)); !errors.Is(err, ErrExpired) { + t.Fatalf("want ErrExpired, got %v", err) + } + if _, err := verifyAt(s, sig, time.Unix(1699999999, 0)); err != nil { + t.Fatalf("not-yet-expired badge should verify: %v", err) + } +} + +func TestMalformed(t *testing.T) { + cases := []string{ + "", + "nope:v1:1:github:0:0:v1:", + "pilotbadge:v9:1:github:0:0:v1:", + "pilotbadge:v1:notanumber:github:0:0:v1:", + "pilotbadge:v1:1:github:0:0:v1", // 7 fields + } + for _, c := range cases { + if _, err := Parse(c); !errors.Is(err, ErrMalformed) { + t.Errorf("Parse(%q): want ErrMalformed, got %v", c, err) + } + } +} + +func TestColonRejectedInFields(t *testing.T) { + b := validBadge() + b.Subject = "ev:il" + if _, err := Canonical(b); !errors.Is(err, ErrMalformed) { + t.Fatalf("colon in subject must be rejected, got %v", err) + } +} diff --git a/badgeverify/credential.go b/badgeverify/credential.go new file mode 100644 index 0000000..3c20c41 --- /dev/null +++ b/badgeverify/credential.go @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package badgeverify + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +// This file adds the two registry-facing credential types that share the +// badge's signing primitive and keyring but carry distinct domain- +// separation prefixes (see badgeverify.go): +// +// - Enrollment: signed by the BADGE-issuer key, records the durable +// address -> identity-commitment binding that recovery later matches. +// - Recovery: signed by the COLD RECOVERY-authority key (a different +// kid), authorizes force-rotating an address to a new key. It MUST be +// single-use (nonce, tracked by the registry) and short-lived (exp). +// +// Apps never handle these — only the registry (and the verifier, which +// produces them) does. + +// Enrollment binds an address to a salted identity commitment. The raw +// external identity is never present; Commitment is HMAC(verifier_salt, +// external_id), opaque to everyone but the verifier. +type Enrollment struct { + NodeID uint32 + Provider string + Commitment string // base64/hex HMAC; never the raw identity + IssuedAt int64 + Kid string +} + +// Recovery authorizes rotating NodeID to NewPubKey because the enrolled +// identity (Commitment) was re-proven. NewPubKey is bound into the signed +// bytes, so an intercepted authorization is useless to anyone who does not +// hold NewPubKey's private key. +type Recovery struct { + NodeID uint32 + NewPubKey string // base64 Ed25519 public key the address rotates to + Commitment string + Exp int64 // unix seconds; MUST be non-zero and is enforced + Nonce string // single-use; the registry rejects replays + Kid string +} + +// CanonicalEnrollment builds the signed bytes for an enrollment. +// Layout: pilotenroll:v1::::: +func CanonicalEnrollment(e Enrollment) (string, error) { + for _, c := range []struct{ name, v string }{ + {"provider", e.Provider}, {"commitment", e.Commitment}, {"kid", e.Kid}, + } { + if c.v == "" { + return "", fmt.Errorf("%w: enrollment %s is required", ErrMalformed, c.name) + } + if err := noColon(c.name, c.v); err != nil { + return "", err + } + } + return fmt.Sprintf("%s:%s:%d:%s:%s:%d:%s", + prefixEnroll, Version, e.NodeID, e.Provider, e.Commitment, e.IssuedAt, e.Kid), nil +} + +// CanonicalRecovery builds the signed bytes for a recovery authorization. +// Layout: pilotrecover:v1:::::: +// +// Exp must be non-zero: a recovery authorization that never expires would +// be a permanent, replayable takeover token. +func CanonicalRecovery(r Recovery) (string, error) { + if r.Exp <= 0 { + return "", fmt.Errorf("%w: recovery exp must be set (non-zero)", ErrMalformed) + } + for _, c := range []struct{ name, v string }{ + {"new_pubkey", r.NewPubKey}, {"commitment", r.Commitment}, {"nonce", r.Nonce}, {"kid", r.Kid}, + } { + if c.v == "" { + return "", fmt.Errorf("%w: recovery %s is required", ErrMalformed, c.name) + } + if err := noColon(c.name, c.v); err != nil { + return "", err + } + } + return fmt.Sprintf("%s:%s:%d:%s:%s:%d:%s:%s", + prefixRecover, Version, r.NodeID, r.NewPubKey, r.Commitment, r.Exp, r.Nonce, r.Kid), nil +} + +// ParseEnrollment decodes an enrollment string WITHOUT verifying its +// signature. It rejects any non-enrollment prefix (domain separation). +func ParseEnrollment(s string) (Enrollment, error) { + parts := strings.Split(s, ":") + if len(parts) != 7 { + return Enrollment{}, fmt.Errorf("%w: enrollment wants 7 fields, got %d", ErrMalformed, len(parts)) + } + if parts[0] != prefixEnroll { + return Enrollment{}, fmt.Errorf("%w: not an enrollment (prefix %q)", ErrMalformed, parts[0]) + } + if parts[1] != Version { + return Enrollment{}, fmt.Errorf("%w: unsupported version %q", ErrMalformed, parts[1]) + } + nodeID, err := strconv.ParseUint(parts[2], 10, 32) + if err != nil { + return Enrollment{}, fmt.Errorf("%w: node_id: %v", ErrMalformed, err) + } + issuedAt, err := strconv.ParseInt(parts[5], 10, 64) + if err != nil { + return Enrollment{}, fmt.Errorf("%w: issued_at: %v", ErrMalformed, err) + } + if parts[3] == "" || parts[4] == "" || parts[6] == "" { + return Enrollment{}, fmt.Errorf("%w: enrollment has empty required field", ErrMalformed) + } + return Enrollment{ + NodeID: uint32(nodeID), Provider: parts[3], Commitment: parts[4], + IssuedAt: issuedAt, Kid: parts[6], + }, nil +} + +// ParseRecovery decodes a recovery string WITHOUT verifying its signature. +// It rejects any non-recovery prefix (domain separation). +func ParseRecovery(s string) (Recovery, error) { + parts := strings.Split(s, ":") + if len(parts) != 8 { + return Recovery{}, fmt.Errorf("%w: recovery wants 8 fields, got %d", ErrMalformed, len(parts)) + } + if parts[0] != prefixRecover { + return Recovery{}, fmt.Errorf("%w: not a recovery (prefix %q)", ErrMalformed, parts[0]) + } + if parts[1] != Version { + return Recovery{}, fmt.Errorf("%w: unsupported version %q", ErrMalformed, parts[1]) + } + nodeID, err := strconv.ParseUint(parts[2], 10, 32) + if err != nil { + return Recovery{}, fmt.Errorf("%w: node_id: %v", ErrMalformed, err) + } + exp, err := strconv.ParseInt(parts[5], 10, 64) + if err != nil { + return Recovery{}, fmt.Errorf("%w: exp: %v", ErrMalformed, err) + } + if parts[3] == "" || parts[4] == "" || parts[6] == "" || parts[7] == "" { + return Recovery{}, fmt.Errorf("%w: recovery has empty required field", ErrMalformed) + } + return Recovery{ + NodeID: uint32(nodeID), NewPubKey: parts[3], Commitment: parts[4], + Exp: exp, Nonce: parts[6], Kid: parts[7], + }, nil +} + +// VerifyEnrollment checks the signature of an enrollment against the pinned +// key named by its kid. +func VerifyEnrollment(s, sigB64 string) (Enrollment, error) { + e, err := ParseEnrollment(s) + if err != nil { + return Enrollment{}, err + } + if err := verifyDetached(s, sigB64, e.Kid); err != nil { + return e, err + } + return e, nil +} + +// VerifyRecovery checks a recovery authorization: signature against the +// pinned (cold recovery-authority) key named by its kid, AND that it has +// not expired. The caller (registry) MUST additionally enforce single-use +// of Nonce and that Commitment matches the address's enrolled commitment. +func VerifyRecovery(s, sigB64 string) (Recovery, error) { + return verifyRecoveryAt(s, sigB64, time.Now()) +} + +func verifyRecoveryAt(s, sigB64 string, now time.Time) (Recovery, error) { + r, err := ParseRecovery(s) + if err != nil { + return Recovery{}, err + } + if r.Exp <= 0 { + return r, fmt.Errorf("%w: recovery exp must be non-zero", ErrMalformed) + } + // Recovery verifies ONLY against the cold recovery keyring — never the + // online badge keyring — so a compromised badge key cannot forge one. + if err := verifyDetachedRecovery(s, sigB64, r.Kid); err != nil { + return r, err + } + if now.Unix() > r.Exp { + return r, fmt.Errorf("%w: exp=%d now=%d", ErrExpired, r.Exp, now.Unix()) + } + return r, nil +} diff --git a/badgeverify/redteam_test.go b/badgeverify/redteam_test.go new file mode 100644 index 0000000..32f07cd --- /dev/null +++ b/badgeverify/redteam_test.go @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package badgeverify + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "errors" + "testing" + "time" +) + +// redteam installs a two-key world: an ONLINE badge key (kid "bdg") and a +// separate COLD recovery key (kid "rec"), returning a signer for each. This +// mirrors production: badges/enrollments under the online key, recoveries +// under the cold key only. +func redteam(t *testing.T) (signBadge, signRecover func(s string) string, badgePriv, recPriv ed25519.PrivateKey) { + t.Helper() + bPub, bPriv, _ := ed25519.GenerateKey(rand.Reader) + rPub, rPriv, _ := ed25519.GenerateKey(rand.Reader) + + origK, origR := keyringB64, recoveryKeyringB64 + keyringB64 = "bdg=" + base64.StdEncoding.EncodeToString(bPub) + recoveryKeyringB64 = "rec=" + base64.StdEncoding.EncodeToString(rPub) + t.Cleanup(func() { keyringB64, recoveryKeyringB64 = origK, origR }) + + mk := func(p ed25519.PrivateKey) func(string) string { + return func(s string) string { return base64.StdEncoding.EncodeToString(ed25519.Sign(p, []byte(s))) } + } + return mk(bPriv), mk(rPriv), bPriv, rPriv +} + +// ATTACK: forge a badge with an attacker key the verifier doesn't pin. +func TestRedteam_ForgedBadgeRejected(t *testing.T) { + redteam(t) + _, attackerPriv, _ := ed25519.GenerateKey(rand.Reader) + s, _ := Canonical(Badge{NodeID: 1, Provider: "github", Kid: "bdg"}) + forged := base64.StdEncoding.EncodeToString(ed25519.Sign(attackerPriv, []byte(s))) + if _, err := Verify(s, forged); !errors.Is(err, ErrBadSignature) { + t.Fatalf("forged badge must be rejected, got %v", err) + } +} + +// ATTACK: tamper each field of a legitimately-signed badge. +func TestRedteam_TamperedBadgeRejected(t *testing.T) { + signBadge, _, _, _ := redteam(t) + orig := Badge{NodeID: 1, Provider: "github", VerifiedAt: 100, Exp: 0, Kid: "bdg"} + s, _ := Canonical(orig) + sig := signBadge(s) + + tampers := []Badge{ + {NodeID: 2, Provider: "github", VerifiedAt: 100, Kid: "bdg"}, // escalate node + {NodeID: 1, Provider: "workos", VerifiedAt: 100, Kid: "bdg"}, // upgrade provider + {NodeID: 1, Provider: "github", VerifiedAt: 999, Kid: "bdg"}, // backdate + {NodeID: 1, Provider: "github", VerifiedAt: 100, Subject: "acme", Kid: "bdg"}, // inject label + } + for _, tb := range tampers { + ts, _ := Canonical(tb) + if _, err := Verify(ts, sig); !errors.Is(err, ErrBadSignature) { + t.Errorf("tampered badge %+v must fail, got %v", tb, err) + } + } +} + +// ATTACK: steal a valid badge and present it as your own node. +func TestRedteam_BadgeReplayToOtherNodeRejected(t *testing.T) { + signBadge, _, _, _ := redteam(t) + s, _ := Canonical(Badge{NodeID: 0xAAAA, Provider: "github", Kid: "bdg"}) + sig := signBadge(s) + if _, err := VerifyForNode(s, sig, 0xBBBB); !errors.Is(err, ErrNodeMismatch) { + t.Fatalf("badge replay to another node must fail, got %v", err) + } +} + +// ATTACK: relabel a badge's kid to the recovery kid to dodge key controls. +func TestRedteam_KidSwapRejected(t *testing.T) { + signBadge, _, _, _ := redteam(t) + // Sign under the badge key but claim kid "rec". + s, _ := Canonical(Badge{NodeID: 1, Provider: "github", Kid: "rec"}) + sig := signBadge(s) + // Badge verify uses the ONLINE keyring, which has no "rec" → fail closed. + if _, err := Verify(s, sig); !errors.Is(err, ErrNoKey) { + t.Fatalf("kid-swap to recovery kid must fail closed, got %v", err) + } +} + +// ATTACK (marquee): cross-statement confusion. A signature over one +// credential type must never validate as another. +func TestRedteam_CrossStatementConfusionRejected(t *testing.T) { + signBadge, signRecover, _, _ := redteam(t) + + // A valid recovery, signed by the cold key. + rec, _ := CanonicalRecovery(Recovery{NodeID: 1, NewPubKey: "np", Commitment: "C", Exp: time.Now().Add(time.Minute).Unix(), Nonce: "n1", Kid: "rec"}) + recSig := signRecover(rec) + if _, err := VerifyRecovery(rec, recSig); err != nil { + t.Fatalf("control: legit recovery should verify: %v", err) + } + // The recovery string/sig must NOT parse or verify as a badge or enrollment. + if _, err := Verify(rec, recSig); !errors.Is(err, ErrMalformed) { + t.Errorf("recovery accepted as badge: %v", err) + } + if _, err := VerifyEnrollment(rec, recSig); !errors.Is(err, ErrMalformed) { + t.Errorf("recovery accepted as enrollment: %v", err) + } + + // And a badge must never be accepted as a recovery authorization. + b, _ := Canonical(Badge{NodeID: 1, Provider: "github", Kid: "bdg"}) + bSig := signBadge(b) + if _, err := VerifyRecovery(b, bSig); !errors.Is(err, ErrMalformed) { + t.Errorf("badge accepted as recovery: %v", err) + } +} + +// ATTACK (marquee): the online badge key must NOT be able to mint a +// recovery — that is the whole point of the cold-key split. +func TestRedteam_OnlineKeyCannotForgeRecovery(t *testing.T) { + signBadge, _, _, _ := redteam(t) + // Craft a recovery that NAMES the recovery kid but is signed by the + // online badge key. Recovery verifies against the cold keyring only. + rec, _ := CanonicalRecovery(Recovery{NodeID: 1, NewPubKey: "np", Commitment: "C", Exp: time.Now().Add(time.Minute).Unix(), Nonce: "n", Kid: "rec"}) + forged := signBadge(rec) + if _, err := VerifyRecovery(rec, forged); !errors.Is(err, ErrBadSignature) { + t.Fatalf("badge key must not be able to sign a recovery, got %v", err) + } + // And naming the badge kid in a recovery fails closed (no such cold key). + rec2, _ := CanonicalRecovery(Recovery{NodeID: 1, NewPubKey: "np", Commitment: "C", Exp: time.Now().Add(time.Minute).Unix(), Nonce: "n", Kid: "bdg"}) + if _, err := VerifyRecovery(rec2, signBadge(rec2)); !errors.Is(err, ErrNoKey) { + t.Fatalf("recovery under badge kid must fail closed, got %v", err) + } +} + +// ATTACK: a never-expiring recovery (permanent replayable takeover token). +func TestRedteam_RecoveryMustExpire(t *testing.T) { + _, signRecover, _, _ := redteam(t) + // CanonicalRecovery refuses exp<=0; build the wire string by hand to + // simulate a hostile issuer/tamper, then verify it is rejected. + s := "pilotrecover:" + Version + ":1:np:C:0:n:rec" + if _, err := verifyRecoveryAt(s, signRecover(s), time.Now()); !errors.Is(err, ErrMalformed) { + t.Fatalf("recovery with exp=0 must be rejected, got %v", err) + } +} + +// ATTACK: replay an expired recovery authorization. +func TestRedteam_ExpiredRecoveryRejected(t *testing.T) { + _, signRecover, _, _ := redteam(t) + rec, _ := CanonicalRecovery(Recovery{NodeID: 1, NewPubKey: "np", Commitment: "C", Exp: 1000, Nonce: "n", Kid: "rec"}) + sig := signRecover(rec) + if _, err := verifyRecoveryAt(rec, sig, time.Unix(1001, 0)); !errors.Is(err, ErrExpired) { + t.Fatalf("expired recovery must be rejected, got %v", err) + } +} + +// ATTACK: malformed / oversized / garbage inputs must never panic and must +// fail closed across all three verifiers. +func TestRedteam_MalformedInputsNoPanic(t *testing.T) { + redteam(t) + junk := []string{ + "", ":", "::::::::", "pilotbadge", "pilotrecover:v1:1:np:C:notanint:n:rec", + "pilotbadge:v1:99999999999999999999:github:0:0:bdg:", // node overflow + string(make([]byte, 4096)), + } + for _, j := range junk { + // Must return an error, never panic. + _, _ = Verify(j, "") + _, _ = VerifyEnrollment(j, "") + _, _ = VerifyRecovery(j, "") + _, _ = Verify(j, "!!!notbase64") + } +} diff --git a/protocol/header.go b/protocol/header.go index edc3dd1..31aac7d 100644 --- a/protocol/header.go +++ b/protocol/header.go @@ -47,6 +47,7 @@ const ( PortStdIO uint16 = 1000 PortDataExchange uint16 = 1001 PortEventStream uint16 = 1002 + PortVerify uint16 = 1003 ) // Port ranges