Skip to content
Open
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
2 changes: 2 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ jobs:
run: npm test
- working-directory: web
run: npm audit --omit=dev
- name: vendored crypto bundles match pinned packages
run: ./web/tools/verify-vendor.sh

# The Go and web jobs together are the byte-identical crypto gate: go test
# writes/asserts testdata/ vectors and npm test asserts the JS side matches the
Expand Down
6 changes: 6 additions & 0 deletions go/cmd/mir-signal/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,12 @@ func setStaticSecurityHeaders(w http.ResponseWriter, nonce string) {
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
// HSTS at the origin (defense in depth; the CDN may also set it). This host is
// a passkey/crypto trust root, so pin TLS for future visits. No
// includeSubDomains — this asserts only for the exact host it is served from,
// so it cannot force HSTS onto sibling subdomains. Browsers ignore it over
// plain HTTP (e.g. local dev), so it is safe to send unconditionally.
w.Header().Set("Strict-Transport-Security", "max-age=31536000")
w.Header().Set("Permissions-Policy", "camera=(self), microphone=(), geolocation=(), payment=(), usb=(), serial=()")
// The SPA currently has unhashed /src and /vendor paths. Prefer freshness over
// stale trusted-code delivery until a content-hashed build exists.
Expand Down
21 changes: 20 additions & 1 deletion go/internal/agent/lan.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"net"
"strconv"
"time"

"github.com/grandcat/zeroconf"

Expand All @@ -16,6 +17,13 @@ import (
// browse for this to discover an agent's ephemeral QUIC port on the local network.
const lanService = "_miranda._udp"

// lanPreAuthTimeout bounds the pre-authentication phase of a LAN-direct
// connection — accepting the peer's stream and reading the owner binding
// (frame 0). A peer that opens a QUIC connection but then stalls must not hold an
// admit() slot indefinitely; it fails here and releases the slot. The full Noise
// session that follows runs under the agent's long-lived context, not this one.
const lanPreAuthTimeout = 10 * time.Second

// startLAN opens a QUIC listener for LAN-direct attach and advertises it over mDNS.
// Returns the bound address (for callers/tests) and a stop func. Each connection runs
// the same binding-gated authenticated session as the relay path.
Expand Down Expand Up @@ -86,7 +94,18 @@ func (rt *Runtime) lanAccept(ctx context.Context, conn *quicmsg.Conn) {
}
defer rt.release()

bindingJSON, err := conn.Recv(ctx) // frame 0
// Bound the whole pre-auth phase: accepting the peer's stream and reading the
// owner binding. A silent peer fails here rather than parking on an admit()
// slot forever. serveAuthenticated below uses ctx (not authCtx) so an
// authenticated session is not cut off after the pre-auth window.
authCtx, cancel := context.WithTimeout(ctx, lanPreAuthTimeout)
defer cancel()

if err := conn.Establish(authCtx); err != nil {
return // peer never opened a stream in time
}

bindingJSON, err := conn.Recv(authCtx) // frame 0
if err != nil {
return
}
Expand Down
34 changes: 30 additions & 4 deletions go/internal/client/keychain.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,23 @@ func (s commandSecretStore) Get(ref string) ([]byte, error) {

func (s commandSecretStore) Put(ref string, secret []byte) error {
var cmd *exec.Cmd
var input []byte
if runtime.GOOS == "darwin" {
// With -w last, security reads the password from stdin. It never appears in
// argv (and therefore not in ps output, shell history, or diagnostics).
cmd = exec.Command(s.path, "add-generic-password", "-a", ref, "-s", keychainService, "-U", "-w")
// See darwinPutInput: `add-generic-password ... -w` (flag last, no value)
// does NOT read the secret from piped stdin — security prompts via
// readpassphrase and silently stores an EMPTY secret when stdin is a pipe.
// Drive interactive command mode so the whole command, secret included,
// travels on stdin — never argv (no ps exposure), never a tty prompt.
in, err := darwinPutInput(ref, keychainService, secret)
if err != nil {
return fmt.Errorf("%s: could not store owner secret (plaintext fallback is disabled)", s.kind)
}
cmd = exec.Command(s.path, "-i")
input = in
} else {
cmd = exec.Command(s.path, "store", "--label=Miranda owner identity", "service", keychainService, "owner", ref)
input = append(append([]byte(nil), secret...), '\n')
}
input := append(append([]byte(nil), secret...), '\n')
defer zeroBytes(input)
cmd.Stdin = bytes.NewReader(input)
cmd.Stdout = io.Discard
Expand All @@ -113,6 +122,23 @@ func (s commandSecretStore) Delete(ref string) error {
return nil
}

// darwinPutInput builds the stdin fed to `security -i` for a keychain write.
// The secret must be safe to place on the interactive command line (which lives
// entirely on stdin, never argv): no whitespace that would end the token early
// and no quote/backslash that `security`'s tokenizer would treat specially. The
// owner secret is hex, so this always holds; the guard is fail-closed defense in
// depth against a caller passing raw bytes. Returned bytes hold the secret — the
// caller must zero them.
func darwinPutInput(ref, service string, secret []byte) ([]byte, error) {
if !secretRefRE.MatchString(ref) || !secretRefRE.MatchString(service) {
return nil, fmt.Errorf("invalid keychain reference")
}
if len(secret) == 0 || bytes.ContainsAny(secret, " \t\r\n\"'\\") {
return nil, fmt.Errorf("secret is not command-line safe")
}
return []byte(fmt.Sprintf("add-generic-password -a %s -s %s -U -w %s\n", ref, service, secret)), nil
}

// fileSecretStore is test-only; platformSecretStore refuses to select it from a
// production binary even if the environment variable is present.
type fileSecretStore struct{ dir string }
Expand Down
76 changes: 76 additions & 0 deletions go/internal/client/keychain_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package client

import (
"bytes"
"strings"
"testing"
)

// TestDarwinPutInputCarriesSecretInBand is the regression guard for the macOS
// keychain bug where `security add-generic-password -w` (flag last, no value)
// read the secret from a tty prompt instead of piped stdin and silently stored
// an EMPTY secret with exit 0 — breaking every `mir list`/`up`/`attach` with
// "owner secret is empty". The fix drives `security -i`, carrying the whole
// command (secret included) on stdin. This must stay true.
func TestDarwinPutInputCarriesSecretInBand(t *testing.T) {
ref := "owner-0123456789abcdef0123456789abcdef"
secret := []byte("a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90") // 64 hex

in, err := darwinPutInput(ref, keychainService, secret)
if err != nil {
t.Fatalf("darwinPutInput returned error for valid input: %v", err)
}
got := string(in)
// The secret must appear verbatim after `-w` (the store bug produced input
// that stored nothing) and the command must end with a newline so `security
// -i` executes it.
if !strings.Contains(got, "-w "+string(secret)+"\n") {
t.Fatalf("secret not carried in-band after -w; input=%q", got)
}
if !strings.HasPrefix(got, "add-generic-password -a "+ref+" -s "+keychainService+" -U -w ") {
t.Fatalf("unexpected command shape: %q", got)
}
}

func TestDarwinPutInputRejectsUnsafeSecret(t *testing.T) {
ref := "owner-0123456789abcdef0123456789abcdef"
// A secret with whitespace/quotes/backslash could break the command line and
// must be refused (fail-closed) rather than silently mangled.
for _, bad := range [][]byte{
[]byte("has space"),
[]byte("has\nnewline"),
[]byte("has\"quote"),
[]byte("has\\backslash"),
[]byte{}, // empty
} {
if _, err := darwinPutInput(ref, keychainService, bad); err == nil {
t.Fatalf("expected rejection for unsafe secret %q", bad)
}
}
}

func TestDarwinPutInputRejectsBadRef(t *testing.T) {
secret := []byte("deadbeef")
for _, bad := range []string{"", "has space", "has/slash", "a;rm -rf", strings.Repeat("x", 200)} {
if _, err := darwinPutInput(bad, keychainService, secret); err == nil {
t.Fatalf("expected rejection for bad ref %q", bad)
}
}
}

// TestDarwinPutInputSecretNotInArgv documents the security property: the secret
// travels only via the returned stdin bytes, so it never reaches the process
// argv (ps/shell-history exposure). This is a shape assertion, not a live exec.
func TestDarwinPutInputSecretNotInArgv(t *testing.T) {
ref := "owner-0123456789abcdef0123456789abcdef"
secret := []byte("cafebabecafebabe")
in, err := darwinPutInput(ref, keychainService, secret)
if err != nil {
t.Fatal(err)
}
// The command is `security -i`; argv holds no secret. Confirm the secret is
// present in the stdin payload only.
if !bytes.Contains(in, secret) {
t.Fatal("secret missing from stdin payload")
}
}
4 changes: 4 additions & 0 deletions go/internal/client/lan_locator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ func TestLANLocatorDialSendsBinding(t *testing.T) {
gotErr <- err
return
}
if err := conn.Establish(actx); err != nil {
gotErr <- err
return
}
frame, err := conn.Recv(actx)
if err != nil {
gotErr <- err
Expand Down
30 changes: 21 additions & 9 deletions go/internal/quicmsg/quicmsg.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,25 +226,37 @@ func Listen(addr string) (*Listener, error) {
// the ephemeral port).
func (l *Listener) Addr() net.Addr { return l.ln.Addr() }

// Accept waits for the next QUIC connection, accepts its first bidirectional
// stream, consumes the open-nudge frame Dial sent, and wraps it in a *Conn.
// Accept waits for the next QUIC connection and wraps it in a *Conn. The Conn is
// NOT yet usable for Send/Recv: the caller must call Establish (under a deadline,
// off the accept path) to accept the peer's stream and consume the open-nudge
// frame. Splitting these two phases is deliberate — doing AcceptStream inline
// here would let a peer that completes the QUIC handshake but never opens a
// stream block the entire accept loop indefinitely (head-of-line DoS). Accept
// only blocks on the genuine "wait for the next connection" step.
func (l *Listener) Accept(ctx context.Context) (*Conn, error) {
conn, err := l.ln.Accept(ctx)
if err != nil {
return nil, err
}
stream, err := conn.AcceptStream(ctx)
return &Conn{conn: conn}, nil
}

// Establish accepts the peer's single bidirectional stream and consumes the
// open-nudge frame Dial sent, making the Conn usable for Send/Recv. It honors
// ctx: pass a deadline so a peer that connects but never opens a stream fails
// here (releasing whatever pre-auth budget the caller reserved) instead of
// parking forever. Safe to call exactly once per Conn returned by Accept.
func (c *Conn) Establish(ctx context.Context) error {
stream, err := c.conn.AcceptStream(ctx)
if err != nil {
_ = conn.CloseWithError(0, "")
return nil, err
return err
}
c := &Conn{conn: conn, stream: stream}
c.stream = stream
// Consume the empty open-nudge frame Dial wrote to flush the stream open.
if _, err := c.Recv(ctx); err != nil {
_ = c.Close()
return nil, err
return err
}
return c, nil
return nil
}

// Close closes the listener (does not close already-accepted connections).
Expand Down
7 changes: 7 additions & 0 deletions go/internal/quicmsg/quicmsg_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ func TestConnRoundTripFrames(t *testing.T) {
accepted := make(chan acceptResult, 1)
go func() {
c, err := ln.Accept(ctx)
if err == nil {
err = c.Establish(ctx)
}
accepted <- acceptResult{c, err}
}()

Expand Down Expand Up @@ -119,6 +122,10 @@ func TestRecvRespectsContext(t *testing.T) {
accepted <- nil
return
}
if err := c.Establish(dialCtx); err != nil {
accepted <- nil
return
}
accepted <- c
}()

Expand Down
9 changes: 5 additions & 4 deletions go/internal/selfupdate/cosign.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,11 @@ func (c *Client) verifyChecksumsSignature(rel *Release, sums []byte, note func(s
return fmt.Errorf("cosign is required to verify Miranda releases")
}

// A release cut before signing was introduced carries no .sig/.pem. cosign
// being installed cannot conjure them — fall back rather than hard-fail so
// upgrading FROM an old release still works. (The next signed tag is the
// first one that will actually exercise verification.)
// Fail closed: a release that carries no .sig/.pem is refused. Do NOT soften
// this into a fallback — accepting an unsigned release would let anyone who
// can publish (or MITM) a GitHub release strip the signature and push a
// malicious binary, which is exactly the attack cosign verification exists to
// stop. This matches SECURITY.md's stated guarantee.
if rel.ChecksumsSigURL == "" || rel.ChecksumsCertURL == "" {
return fmt.Errorf("release has no cosign signature; refusing update")
}
Expand Down
Loading
Loading