From 7c31a8eb35dc72dbb0c61302ec8f67cf5b6b6bcd Mon Sep 17 00:00:00 2001 From: Nate Brown Date: Thu, 7 May 2026 22:33:14 -0500 Subject: [PATCH] Allow for `-` to stand in for stdin/out --- cmd/nebula-cert/ca.go | 39 ++++++-- cmd/nebula-cert/ca_test.go | 79 ++++++++++++++-- cmd/nebula-cert/keygen.go | 15 ++- cmd/nebula-cert/keygen_test.go | 41 ++++++++ cmd/nebula-cert/passwords.go | 4 +- cmd/nebula-cert/print.go | 31 ++++-- cmd/nebula-cert/print_test.go | 39 ++++++++ cmd/nebula-cert/sign.go | 62 ++++++++---- cmd/nebula-cert/sign_test.go | 120 +++++++++++++++++++++-- cmd/nebula-cert/stdio.go | 117 +++++++++++++++++++++++ cmd/nebula-cert/stdio_test.go | 167 +++++++++++++++++++++++++++++++++ cmd/nebula-cert/verify.go | 17 +++- cmd/nebula-cert/verify_test.go | 44 +++++++++ 13 files changed, 718 insertions(+), 57 deletions(-) create mode 100644 cmd/nebula-cert/stdio.go create mode 100644 cmd/nebula-cert/stdio_test.go diff --git a/cmd/nebula-cert/ca.go b/cmd/nebula-cert/ca.go index cd9b82f9..3145f445 100644 --- a/cmd/nebula-cert/ca.go +++ b/cmd/nebula-cert/ca.go @@ -97,6 +97,19 @@ func ca(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) error if err = mustFlagString("out-key", cf.outKeyPath); err != nil { return err } + } else { + // out-key is meaningless under PKCS#11 because the private key never + // leaves the HSM; reject it so we never silently accept or claim a + // stdout slot for it. + outKeySet := false + cf.set.Visit(func(f *flag.Flag) { + if f.Name == "out-key" { + outKeySet = true + } + }) + if outKeySet { + return newHelpErrorf("cannot set -out-key with -pkcs11") + } } if err := mustFlagString("out-crt", cf.outCertPath); err != nil { return err @@ -171,12 +184,21 @@ func ca(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) error } } + var claims ioClaims + if err := reserveOutputs(&claims, + "out-key", *cf.outKeyPath, + "out-crt", *cf.outCertPath, + "out-qr", *cf.outQRPath, + ); err != nil { + return err + } + var passphrase []byte if !isP11 && *cf.encryption { passphrase = []byte(os.Getenv("NEBULA_CA_PASSPHRASE")) if len(passphrase) == 0 { for i := 0; i < 5; i++ { - out.Write([]byte("Enter passphrase: ")) + errOut.Write([]byte("Enter passphrase: ")) passphrase, err = pr.ReadPassword() if err == ErrNoTerminal { @@ -261,14 +283,16 @@ func ca(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) error Curve: curve, } - if !isP11 { + if !isP11 && !isStdio(*cf.outKeyPath) { if _, err := os.Stat(*cf.outKeyPath); err == nil { return fmt.Errorf("refusing to overwrite existing CA key: %s", *cf.outKeyPath) } } - if _, err := os.Stat(*cf.outCertPath); err == nil { - return fmt.Errorf("refusing to overwrite existing CA cert: %s", *cf.outCertPath) + if !isStdio(*cf.outCertPath) { + if _, err := os.Stat(*cf.outCertPath); err == nil { + return fmt.Errorf("refusing to overwrite existing CA cert: %s", *cf.outCertPath) + } } var c cert.Certificate @@ -294,7 +318,7 @@ func ca(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) error b = cert.MarshalSigningPrivateKeyToPEM(curve, rawPriv) } - err = os.WriteFile(*cf.outKeyPath, b, 0600) + err = writeOutput(*cf.outKeyPath, b, 0600, out) if err != nil { return fmt.Errorf("error while writing out-key: %s", err) } @@ -305,7 +329,7 @@ func ca(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) error return fmt.Errorf("error while marshalling certificate: %s", err) } - err = os.WriteFile(*cf.outCertPath, b, 0600) + err = writeOutput(*cf.outCertPath, b, 0600, out) if err != nil { return fmt.Errorf("error while writing out-crt: %s", err) } @@ -316,7 +340,7 @@ func ca(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) error return fmt.Errorf("error while generating qr code: %s", err) } - err = os.WriteFile(*cf.outQRPath, b, 0600) + err = writeOutput(*cf.outQRPath, b, 0600, out) if err != nil { return fmt.Errorf("error while writing out-qr: %s", err) } @@ -332,6 +356,7 @@ func caSummary() string { func caHelp(out io.Writer) { cf := newCaFlags() out.Write([]byte("Usage of " + os.Args[0] + " " + caSummary() + "\n")) + out.Write([]byte(stdioHelpText)) cf.set.SetOutput(out) cf.set.PrintDefaults() } diff --git a/cmd/nebula-cert/ca_test.go b/cmd/nebula-cert/ca_test.go index 779d3a2d..ce0113b6 100644 --- a/cmd/nebula-cert/ca_test.go +++ b/cmd/nebula-cert/ca_test.go @@ -27,6 +27,7 @@ func Test_caHelp(t *testing.T) { assert.Equal( t, "Usage of "+os.Args[0]+" ca : create a self signed certificate authority\n"+ + " Pass \"-\" to any path flag to read from stdin or write to stdout.\n"+ " -argon-iterations uint\n"+ " \tOptional: Argon2 iterations parameter used for encrypted private key passphrase (default 1)\n"+ " -argon-memory uint\n"+ @@ -84,7 +85,7 @@ func Test_ca(t *testing.T) { err: nil, } - pwPromptOb := "Enter passphrase: " + pwPromptEB := "Enter passphrase: " // required args assertHelpError(t, ca( @@ -168,8 +169,8 @@ func Test_ca(t *testing.T) { eb.Reset() args = []string{"-version", "1", "-encrypt", "-name", "test", "-duration", "100m", "-groups", "1,2,3,4,5", "-out-crt", crtF.Name(), "-out-key", keyF.Name()} require.NoError(t, ca(args, ob, eb, testpw)) - assert.Equal(t, pwPromptOb, ob.String()) - assert.Empty(t, eb.String()) + assert.Empty(t, ob.String()) + assert.Equal(t, pwPromptEB, eb.String()) // test encrypted key with passphrase environment variable os.Remove(keyF.Name()) @@ -207,8 +208,8 @@ func Test_ca(t *testing.T) { eb.Reset() args = []string{"-version", "1", "-encrypt", "-name", "test", "-duration", "100m", "-groups", "1,2,3,4,5", "-out-crt", crtF.Name(), "-out-key", keyF.Name()} require.Error(t, ca(args, ob, eb, errpw)) - assert.Equal(t, pwPromptOb, ob.String()) - assert.Empty(t, eb.String()) + assert.Empty(t, ob.String()) + assert.Equal(t, pwPromptEB, eb.String()) // test when user fails to enter a password os.Remove(keyF.Name()) @@ -217,8 +218,8 @@ func Test_ca(t *testing.T) { eb.Reset() args = []string{"-version", "1", "-encrypt", "-name", "test", "-duration", "100m", "-groups", "1,2,3,4,5", "-out-crt", crtF.Name(), "-out-key", keyF.Name()} require.EqualError(t, ca(args, ob, eb, nopw), "no passphrase specified, remove -encrypt flag to write out-key in plaintext") - assert.Equal(t, strings.Repeat(pwPromptOb, 5), ob.String()) // prompts 5 times before giving up - assert.Empty(t, eb.String()) + assert.Empty(t, ob.String()) + assert.Equal(t, strings.Repeat(pwPromptEB, 5), eb.String()) // prompts 5 times before giving up // create valid cert/key for overwrite tests os.Remove(keyF.Name()) @@ -247,3 +248,67 @@ func Test_ca(t *testing.T) { os.Remove(keyF.Name()) } + +func Test_ca_stdio(t *testing.T) { + nopw := &StubPasswordReader{} + + keyF, err := os.CreateTemp("", "ca.key") + require.NoError(t, err) + os.Remove(keyF.Name()) + defer os.Remove(keyF.Name()) + + crtF, err := os.CreateTemp("", "ca.crt") + require.NoError(t, err) + os.Remove(crtF.Name()) + defer os.Remove(crtF.Name()) + + // out-crt on stdout, out-key on disk + ob := &bytes.Buffer{} + eb := &bytes.Buffer{} + require.NoError(t, ca([]string{"-name", "test-ca", "-duration", "1h", "-out-crt", "-", "-out-key", keyF.Name()}, ob, eb, nopw)) + assert.Empty(t, eb.String()) + c, _, err := cert.UnmarshalCertificateFromPEM(ob.Bytes()) + require.NoError(t, err) + assert.True(t, c.IsCA()) + assert.Equal(t, "test-ca", c.Name()) + + // out-key on stdout, out-crt on disk + os.Remove(keyF.Name()) + ob.Reset() + eb.Reset() + require.NoError(t, ca([]string{"-name", "test-ca", "-duration", "1h", "-out-crt", crtF.Name(), "-out-key", "-"}, ob, eb, nopw)) + assert.Empty(t, eb.String()) + _, _, curve, err := cert.UnmarshalSigningPrivateKeyFromPEM(ob.Bytes()) + require.NoError(t, err) + assert.Equal(t, cert.Curve_CURVE25519, curve) + + // dual stdout is rejected up front + os.Remove(crtF.Name()) + ob.Reset() + eb.Reset() + require.EqualError(t, + ca([]string{"-name", "test-ca", "-duration", "1h", "-out-crt", "-", "-out-key", "-"}, ob, eb, nopw), + `-out-key and -out-crt both set to "-", only one output may write to stdout`) + assert.Empty(t, ob.String()) + + // an output conflict combined with -encrypt must error BEFORE prompting + // for a passphrase; pr would record any read attempt + tracker := &trackingPasswordReader{} + ob.Reset() + eb.Reset() + require.EqualError(t, + ca([]string{"-name", "test-ca", "-duration", "1h", "-encrypt", "-out-crt", "-", "-out-key", "-"}, ob, eb, tracker), + `-out-key and -out-crt both set to "-", only one output may write to stdout`) + assert.Empty(t, ob.String()) + assert.Empty(t, eb.String()) + assert.Zero(t, tracker.calls, "passphrase prompt should not have been called") +} + +type trackingPasswordReader struct { + calls int +} + +func (pr *trackingPasswordReader) ReadPassword() ([]byte, error) { + pr.calls++ + return []byte(""), nil +} diff --git a/cmd/nebula-cert/keygen.go b/cmd/nebula-cert/keygen.go index 496f84c2..dea6c4af 100644 --- a/cmd/nebula-cert/keygen.go +++ b/cmd/nebula-cert/keygen.go @@ -42,6 +42,8 @@ func keygen(args []string, out io.Writer, errOut io.Writer) error { if err = mustFlagString("out-key", cf.outKeyPath); err != nil { return err } + } else if *cf.outKeyPath != "" { + return newHelpErrorf("cannot set -out-key with -pkcs11") } if err = mustFlagString("out-pub", cf.outPubPath); err != nil { return err @@ -69,6 +71,14 @@ func keygen(args []string, out io.Writer, errOut io.Writer) error { } } + var claims ioClaims + if err := reserveOutputs(&claims, + "out-key", *cf.outKeyPath, + "out-pub", *cf.outPubPath, + ); err != nil { + return err + } + if isP11 { p11Client, err := pkclient.FromUrl(*cf.p11url) if err != nil { @@ -82,12 +92,12 @@ func keygen(args []string, out io.Writer, errOut io.Writer) error { return fmt.Errorf("error while getting public key: %w", err) } } else { - err = os.WriteFile(*cf.outKeyPath, cert.MarshalPrivateKeyToPEM(curve, rawPriv), 0600) + err = writeOutput(*cf.outKeyPath, cert.MarshalPrivateKeyToPEM(curve, rawPriv), 0600, out) if err != nil { return fmt.Errorf("error while writing out-key: %s", err) } } - err = os.WriteFile(*cf.outPubPath, cert.MarshalPublicKeyToPEM(curve, pub), 0600) + err = writeOutput(*cf.outPubPath, cert.MarshalPublicKeyToPEM(curve, pub), 0600, out) if err != nil { return fmt.Errorf("error while writing out-pub: %s", err) } @@ -102,6 +112,7 @@ func keygenSummary() string { func keygenHelp(out io.Writer) { cf := newKeygenFlags() _, _ = out.Write([]byte("Usage of " + os.Args[0] + " " + keygenSummary() + "\n")) + _, _ = out.Write([]byte(stdioHelpText)) cf.set.SetOutput(out) cf.set.PrintDefaults() } diff --git a/cmd/nebula-cert/keygen_test.go b/cmd/nebula-cert/keygen_test.go index 95d9893e..98c4c456 100644 --- a/cmd/nebula-cert/keygen_test.go +++ b/cmd/nebula-cert/keygen_test.go @@ -20,6 +20,7 @@ func Test_keygenHelp(t *testing.T) { assert.Equal( t, "Usage of "+os.Args[0]+" keygen : create a public/private key pair. the public key can be passed to `nebula-cert sign`\n"+ + " Pass \"-\" to any path flag to read from stdin or write to stdout.\n"+ " -curve string\n"+ " \tECDH Curve (25519, P256) (default \"25519\")\n"+ " -out-key string\n"+ @@ -93,3 +94,43 @@ func Test_keygen(t *testing.T) { require.NoError(t, err) assert.Len(t, lPub, 32) } + +func Test_keygen_stdio(t *testing.T) { + keyF, err := os.CreateTemp("", "test.key") + require.NoError(t, err) + os.Remove(keyF.Name()) + defer os.Remove(keyF.Name()) + + pubF, err := os.CreateTemp("", "test.pub") + require.NoError(t, err) + os.Remove(pubF.Name()) + defer os.Remove(pubF.Name()) + + // out-pub on stdout, out-key on disk + ob := &bytes.Buffer{} + eb := &bytes.Buffer{} + require.NoError(t, keygen([]string{"-out-pub", "-", "-out-key", keyF.Name()}, ob, eb)) + assert.Empty(t, eb.String()) + lPub, _, curve, err := cert.UnmarshalPublicKeyFromPEM(ob.Bytes()) + require.NoError(t, err) + assert.Equal(t, cert.Curve_CURVE25519, curve) + assert.Len(t, lPub, 32) + + // out-key on stdout, out-pub on disk + os.Remove(keyF.Name()) + ob.Reset() + eb.Reset() + require.NoError(t, keygen([]string{"-out-pub", pubF.Name(), "-out-key", "-"}, ob, eb)) + assert.Empty(t, eb.String()) + lKey, _, curve, err := cert.UnmarshalPrivateKeyFromPEM(ob.Bytes()) + require.NoError(t, err) + assert.Equal(t, cert.Curve_CURVE25519, curve) + assert.Len(t, lKey, 32) + + // both on stdout is a conflict caught up front + ob.Reset() + eb.Reset() + require.EqualError(t, keygen([]string{"-out-pub", "-", "-out-key", "-"}, ob, eb), + `-out-key and -out-pub both set to "-", only one output may write to stdout`) + assert.Empty(t, ob.String()) +} diff --git a/cmd/nebula-cert/passwords.go b/cmd/nebula-cert/passwords.go index 8129560e..0aa2115d 100644 --- a/cmd/nebula-cert/passwords.go +++ b/cmd/nebula-cert/passwords.go @@ -22,7 +22,9 @@ func (pr StdinPasswordReader) ReadPassword() ([]byte, error) { } password, err := term.ReadPassword(int(os.Stdin.Fd())) - fmt.Println() + // Terminal echo is off while reading, so the user's Enter key does not + // produce a visible newline. Emit one on stderr to match the prompt. + fmt.Fprintln(os.Stderr) return password, err } diff --git a/cmd/nebula-cert/print.go b/cmd/nebula-cert/print.go index 30e0965b..3ba0571e 100644 --- a/cmd/nebula-cert/print.go +++ b/cmd/nebula-cert/print.go @@ -40,11 +40,23 @@ func printCert(args []string, out io.Writer, errOut io.Writer) error { return err } - rawCert, err := os.ReadFile(*pf.path) + var claims ioClaims + if err := reserveInputs(&claims, "path", *pf.path); err != nil { + return err + } + if err := reserveOutputs(&claims, "out-qr", *pf.outQRPath); err != nil { + return err + } + + rawCert, err := readInput("path", *pf.path, &claims) if err != nil { return fmt.Errorf("unable to read cert; %s", err) } + // When the QR is going to stdout, suppress the human-readable text/json + // output so the binary stream is not contaminated. + qrToStdout := isStdio(*pf.outQRPath) + var c cert.Certificate var qrBytes []byte part := 0 @@ -57,11 +69,13 @@ func printCert(args []string, out io.Writer, errOut io.Writer) error { return fmt.Errorf("error while unmarshaling cert: %s", err) } - if *pf.json { - jsonCerts = append(jsonCerts, c) - } else { - _, _ = out.Write([]byte(c.String())) - _, _ = out.Write([]byte("\n")) + if !qrToStdout { + if *pf.json { + jsonCerts = append(jsonCerts, c) + } else { + _, _ = out.Write([]byte(c.String())) + _, _ = out.Write([]byte("\n")) + } } if *pf.outQRPath != "" { @@ -79,7 +93,7 @@ func printCert(args []string, out io.Writer, errOut io.Writer) error { part++ } - if *pf.json { + if *pf.json && !qrToStdout { b, _ := json.Marshal(jsonCerts) _, _ = out.Write(b) _, _ = out.Write([]byte("\n")) @@ -91,7 +105,7 @@ func printCert(args []string, out io.Writer, errOut io.Writer) error { return fmt.Errorf("error while generating qr code: %s", err) } - err = os.WriteFile(*pf.outQRPath, b, 0600) + err = writeOutput(*pf.outQRPath, b, 0600, out) if err != nil { return fmt.Errorf("error while writing out-qr: %s", err) } @@ -107,6 +121,7 @@ func printSummary() string { func printHelp(out io.Writer) { pf := newPrintFlags() out.Write([]byte("Usage of " + os.Args[0] + " " + printSummary() + "\n")) + out.Write([]byte(stdioHelpText)) pf.set.SetOutput(out) pf.set.PrintDefaults() } diff --git a/cmd/nebula-cert/print_test.go b/cmd/nebula-cert/print_test.go index 221ab778..8d5d31be 100644 --- a/cmd/nebula-cert/print_test.go +++ b/cmd/nebula-cert/print_test.go @@ -25,6 +25,7 @@ func Test_printHelp(t *testing.T) { assert.Equal( t, "Usage of "+os.Args[0]+" print : prints details about a certificate\n"+ + " Pass \"-\" to any path flag to read from stdin or write to stdout.\n"+ " -json\n"+ " \tOptional: outputs certificates in json format\n"+ " -out-qr string\n"+ @@ -178,6 +179,44 @@ func Test_printCert(t *testing.T) { ob.String(), ) assert.Empty(t, eb.String()) + + // read cert from stdin + ob.Reset() + eb.Reset() + withStdin(t, bytes.NewReader(p)) + err = printCert([]string{"-json", "-path", "-"}, ob, eb) + require.NoError(t, err) + assert.Equal( + t, + `[{"details":{"curve":"CURVE25519","groups":["hi"],"isCa":false,"issuer":"`+c.Issuer()+`","name":"test","networks":["10.0.0.123/8"],"notAfter":"0001-01-01T00:00:00Z","notBefore":"0001-01-01T00:00:00Z","publicKey":"`+pk+`","unsafeNetworks":[]},"fingerprint":"`+fp+`","signature":"`+sig+`","version":1}] +`, + ob.String(), + ) + assert.Empty(t, eb.String()) + + // -out-qr - sends only the PNG to stdout, suppressing the cert dump + ob.Reset() + eb.Reset() + withStdin(t, bytes.NewReader(p)) + err = printCert([]string{"-path", "-", "-out-qr", "-"}, ob, eb) + require.NoError(t, err) + assert.Empty(t, eb.String()) + stdout := ob.Bytes() + require.NotEmpty(t, stdout) + // PNG magic, no PEM/JSON noise prepended + assert.Equal(t, []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}, stdout[:8]) + assert.NotContains(t, string(stdout), "NebulaCertificate") + assert.NotContains(t, string(stdout), `"details"`) + + // json + out-qr - still suppresses json + ob.Reset() + eb.Reset() + withStdin(t, bytes.NewReader(p)) + err = printCert([]string{"-json", "-path", "-", "-out-qr", "-"}, ob, eb) + require.NoError(t, err) + assert.Empty(t, eb.String()) + assert.Equal(t, []byte{0x89, 'P', 'N', 'G'}, ob.Bytes()[:4]) + assert.NotContains(t, ob.String(), `"details"`) } // NewTestCaCert will generate a CA cert diff --git a/cmd/nebula-cert/sign.go b/cmd/nebula-cert/sign.go index 561138ca..9b57c4fe 100644 --- a/cmd/nebula-cert/sign.go +++ b/cmd/nebula-cert/sign.go @@ -85,6 +85,9 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) if !isP11 && *sf.inPubPath != "" && *sf.outKeyPath != "" { return newHelpErrorf("cannot set both -in-pub and -out-key") } + if isP11 && *sf.outKeyPath != "" { + return newHelpErrorf("cannot set -out-key with -pkcs11") + } var v4Networks []netip.Prefix var v6Networks []netip.Prefix @@ -102,13 +105,35 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) return newHelpErrorf("-version must be either %v or %v", cert.Version1, cert.Version2) } + if *sf.outKeyPath == "" { + *sf.outKeyPath = *sf.name + ".key" + } + if *sf.outCertPath == "" { + *sf.outCertPath = *sf.name + ".crt" + } + + var claims ioClaims + if err := reserveInputs(&claims, + "ca-key", *sf.caKeyPath, + "ca-crt", *sf.caCertPath, + "in-pub", *sf.inPubPath, + ); err != nil { + return err + } + if err := reserveOutputs(&claims, + "out-key", *sf.outKeyPath, + "out-crt", *sf.outCertPath, + "out-qr", *sf.outQRPath, + ); err != nil { + return err + } + var curve cert.Curve var caKey []byte if !isP11 { var rawCAKey []byte - rawCAKey, err := os.ReadFile(*sf.caKeyPath) - + rawCAKey, err = readInput("ca-key", *sf.caKeyPath, &claims) if err != nil { return fmt.Errorf("error while reading ca-key: %s", err) } @@ -121,7 +146,7 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) if len(passphrase) == 0 { // ask for a passphrase until we get one for i := 0; i < 5; i++ { - out.Write([]byte("Enter passphrase: ")) + errOut.Write([]byte("Enter passphrase: ")) passphrase, err = pr.ReadPassword() if errors.Is(err, ErrNoTerminal) { @@ -147,7 +172,7 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) } } - rawCACert, err := os.ReadFile(*sf.caCertPath) + rawCACert, err := readInput("ca-crt", *sf.caCertPath, &claims) if err != nil { return fmt.Errorf("error while reading ca-crt: %s", err) } @@ -245,7 +270,7 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) if *sf.inPubPath != "" { var pubCurve cert.Curve - rawPub, err := os.ReadFile(*sf.inPubPath) + rawPub, err := readInput("in-pub", *sf.inPubPath, &claims) if err != nil { return fmt.Errorf("error while reading in-pub: %s", err) } @@ -266,16 +291,10 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) pub, rawPriv = newKeypair(curve) } - if *sf.outKeyPath == "" { - *sf.outKeyPath = *sf.name + ".key" - } - - if *sf.outCertPath == "" { - *sf.outCertPath = *sf.name + ".crt" - } - - if _, err := os.Stat(*sf.outCertPath); err == nil { - return fmt.Errorf("refusing to overwrite existing cert: %s", *sf.outCertPath) + if !isStdio(*sf.outCertPath) { + if _, err := os.Stat(*sf.outCertPath); err == nil { + return fmt.Errorf("refusing to overwrite existing cert: %s", *sf.outCertPath) + } } var crts []cert.Certificate @@ -360,11 +379,13 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) } if !isP11 && *sf.inPubPath == "" { - if _, err := os.Stat(*sf.outKeyPath); err == nil { - return fmt.Errorf("refusing to overwrite existing key: %s", *sf.outKeyPath) + if !isStdio(*sf.outKeyPath) { + if _, err := os.Stat(*sf.outKeyPath); err == nil { + return fmt.Errorf("refusing to overwrite existing key: %s", *sf.outKeyPath) + } } - err = os.WriteFile(*sf.outKeyPath, cert.MarshalPrivateKeyToPEM(curve, rawPriv), 0600) + err = writeOutput(*sf.outKeyPath, cert.MarshalPrivateKeyToPEM(curve, rawPriv), 0600, out) if err != nil { return fmt.Errorf("error while writing out-key: %s", err) } @@ -379,7 +400,7 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) b = append(b, sb...) } - err = os.WriteFile(*sf.outCertPath, b, 0600) + err = writeOutput(*sf.outCertPath, b, 0600, out) if err != nil { return fmt.Errorf("error while writing out-crt: %s", err) } @@ -390,7 +411,7 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) return fmt.Errorf("error while generating qr code: %s", err) } - err = os.WriteFile(*sf.outQRPath, b, 0600) + err = writeOutput(*sf.outQRPath, b, 0600, out) if err != nil { return fmt.Errorf("error while writing out-qr: %s", err) } @@ -440,6 +461,7 @@ func signSummary() string { func signHelp(out io.Writer) { sf := newSignFlags() out.Write([]byte("Usage of " + os.Args[0] + " " + signSummary() + "\n")) + out.Write([]byte(stdioHelpText)) sf.set.SetOutput(out) sf.set.PrintDefaults() } diff --git a/cmd/nebula-cert/sign_test.go b/cmd/nebula-cert/sign_test.go index f5f8cbb0..64d5c7d9 100644 --- a/cmd/nebula-cert/sign_test.go +++ b/cmd/nebula-cert/sign_test.go @@ -27,6 +27,7 @@ func Test_signHelp(t *testing.T) { assert.Equal( t, "Usage of "+os.Args[0]+" sign : create and sign a certificate\n"+ + " Pass \"-\" to any path flag to read from stdin or write to stdout.\n"+ " -ca-crt string\n"+ " \tOptional: path to the signing CA cert (default \"ca.crt\")\n"+ " -ca-key string\n"+ @@ -376,15 +377,18 @@ func Test_signCert(t *testing.T) { // test with the proper password args = []string{"-version", "1", "-ca-crt", caCrtF.Name(), "-ca-key", caKeyF.Name(), "-name", "test", "-ip", "1.1.1.1/24", "-out-crt", crtF.Name(), "-out-key", keyF.Name(), "-duration", "100m", "-subnets", "10.1.1.1/32, , 10.2.2.2/32 , , ,, 10.5.5.5/32", "-groups", "1,, 2 , ,,,3,4,5"} require.NoError(t, signCert(args, ob, eb, testpw)) - assert.Equal(t, "Enter passphrase: ", ob.String()) - assert.Empty(t, eb.String()) + assert.Empty(t, ob.String()) + assert.Equal(t, "Enter passphrase: ", eb.String()) // test with the proper password in the environment os.Remove(crtF.Name()) os.Remove(keyF.Name()) args = []string{"-version", "1", "-ca-crt", caCrtF.Name(), "-ca-key", caKeyF.Name(), "-name", "test", "-ip", "1.1.1.1/24", "-out-crt", crtF.Name(), "-out-key", keyF.Name(), "-duration", "100m", "-subnets", "10.1.1.1/32, , 10.2.2.2/32 , , ,, 10.5.5.5/32", "-groups", "1,, 2 , ,,,3,4,5"} os.Setenv("NEBULA_CA_PASSPHRASE", string(passphrase)) + ob.Reset() + eb.Reset() require.NoError(t, signCert(args, ob, eb, testpw)) + assert.Empty(t, ob.String()) assert.Empty(t, eb.String()) os.Setenv("NEBULA_CA_PASSPHRASE", "") @@ -395,8 +399,8 @@ func Test_signCert(t *testing.T) { testpw.password = []byte("invalid password") args = []string{"-version", "1", "-ca-crt", caCrtF.Name(), "-ca-key", caKeyF.Name(), "-name", "test", "-ip", "1.1.1.1/24", "-out-crt", crtF.Name(), "-out-key", keyF.Name(), "-duration", "100m", "-subnets", "10.1.1.1/32, , 10.2.2.2/32 , , ,, 10.5.5.5/32", "-groups", "1,, 2 , ,,,3,4,5"} require.Error(t, signCert(args, ob, eb, testpw)) - assert.Equal(t, "Enter passphrase: ", ob.String()) - assert.Empty(t, eb.String()) + assert.Empty(t, ob.String()) + assert.Equal(t, "Enter passphrase: ", eb.String()) // test with the wrong password in environment ob.Reset() @@ -416,8 +420,8 @@ func Test_signCert(t *testing.T) { args = []string{"-version", "1", "-ca-crt", caCrtF.Name(), "-ca-key", caKeyF.Name(), "-name", "test", "-ip", "1.1.1.1/24", "-out-crt", crtF.Name(), "-out-key", keyF.Name(), "-duration", "100m", "-subnets", "10.1.1.1/32, , 10.2.2.2/32 , , ,, 10.5.5.5/32", "-groups", "1,, 2 , ,,,3,4,5"} require.Error(t, signCert(args, ob, eb, nopw)) // normally the user hitting enter on the prompt would add newlines between these - assert.Equal(t, "Enter passphrase: Enter passphrase: Enter passphrase: Enter passphrase: Enter passphrase: ", ob.String()) - assert.Empty(t, eb.String()) + assert.Empty(t, ob.String()) + assert.Equal(t, "Enter passphrase: Enter passphrase: Enter passphrase: Enter passphrase: Enter passphrase: ", eb.String()) // test an error condition ob.Reset() @@ -425,6 +429,106 @@ func Test_signCert(t *testing.T) { args = []string{"-version", "1", "-ca-crt", caCrtF.Name(), "-ca-key", caKeyF.Name(), "-name", "test", "-ip", "1.1.1.1/24", "-out-crt", crtF.Name(), "-out-key", keyF.Name(), "-duration", "100m", "-subnets", "10.1.1.1/32, , 10.2.2.2/32 , , ,, 10.5.5.5/32", "-groups", "1,, 2 , ,,,3,4,5"} require.Error(t, signCert(args, ob, eb, errpw)) - assert.Equal(t, "Enter passphrase: ", ob.String()) - assert.Empty(t, eb.String()) + assert.Empty(t, ob.String()) + assert.Equal(t, "Enter passphrase: ", eb.String()) +} + +func Test_signCert_stdio(t *testing.T) { + nopw := &StubPasswordReader{ + password: []byte(""), + err: nil, + } + + caPub, caPriv, _ := ed25519.GenerateKey(rand.Reader) + rawCAKey := cert.MarshalSigningPrivateKeyToPEM(cert.Curve_CURVE25519, caPriv) + + ca, _ := NewTestCaCert("ca", caPub, caPriv, time.Now(), time.Now().Add(time.Minute*200), nil, nil, nil) + rawCACrt, _ := ca.MarshalPEM() + + caCrtF, err := os.CreateTemp("", "sign-cert.crt") + require.NoError(t, err) + defer os.Remove(caCrtF.Name()) + caCrtF.Write(rawCACrt) + + caKeyF, err := os.CreateTemp("", "sign-cert.key") + require.NoError(t, err) + defer os.Remove(caKeyF.Name()) + caKeyF.Write(rawCAKey) + + keyF, err := os.CreateTemp("", "sign.key") + require.NoError(t, err) + os.Remove(keyF.Name()) + defer os.Remove(keyF.Name()) + + // ca-key on stdin, cert to stdout + withStdin(t, bytes.NewReader(rawCAKey)) + ob := &bytes.Buffer{} + eb := &bytes.Buffer{} + args := []string{"-version", "1", "-ca-crt", caCrtF.Name(), "-ca-key", "-", "-name", "stdin-test", "-ip", "1.1.1.1/24", "-out-crt", "-", "-out-key", keyF.Name(), "-duration", "100m"} + require.NoError(t, signCert(args, ob, eb, nopw)) + assert.Empty(t, eb.String()) + + lCrt, _, err := cert.UnmarshalCertificateFromPEM(ob.Bytes()) + require.NoError(t, err) + assert.Equal(t, "stdin-test", lCrt.Name()) + assert.True(t, lCrt.CheckSignature(caPub)) + + // two flags reading from stdin should error before any read attempt; + // otherwise an interactive shell would hang on io.ReadAll + stdinIn := bytes.NewReader(rawCAKey) + withStdin(t, stdinIn) + ob.Reset() + eb.Reset() + args = []string{"-version", "1", "-ca-crt", "-", "-ca-key", "-", "-name", "stdin-test", "-ip", "1.1.1.1/24", "-out-crt", "nope", "-out-key", "nope", "-duration", "100m"} + require.EqualError(t, signCert(args, ob, eb, nopw), + `-ca-key and -ca-crt both set to "-", only one input may read from stdin`) + assert.Equal(t, len(rawCAKey), stdinIn.Len(), "stdin should be untouched when conflict is caught up front") + + // two flags writing to stdout should error before any output is written + // AND before stdin is consumed + stdinR := bytes.NewReader(rawCAKey) + withStdin(t, stdinR) + ob.Reset() + eb.Reset() + args = []string{"-version", "1", "-ca-crt", caCrtF.Name(), "-ca-key", "-", "-name", "stdin-test", "-ip", "1.1.1.1/24", "-out-crt", "-", "-out-key", "-", "-duration", "100m"} + require.EqualError(t, signCert(args, ob, eb, nopw), + `-out-key and -out-crt both set to "-", only one output may write to stdout`) + assert.Empty(t, ob.String()) + // stdin should be untouched because the conflict was caught up front + assert.Equal(t, len(rawCAKey), stdinR.Len()) + + // out-key on stdout, cert on disk + keyF2, err := os.CreateTemp("", "sign.key") + require.NoError(t, err) + os.Remove(keyF2.Name()) + defer os.Remove(keyF2.Name()) + crtF, err := os.CreateTemp("", "sign.crt") + require.NoError(t, err) + os.Remove(crtF.Name()) + defer os.Remove(crtF.Name()) + + ob.Reset() + eb.Reset() + args = []string{"-version", "1", "-ca-crt", caCrtF.Name(), "-ca-key", caKeyF.Name(), "-name", "stdin-test", "-ip", "1.1.1.1/24", "-out-crt", crtF.Name(), "-out-key", "-", "-duration", "100m"} + require.NoError(t, signCert(args, ob, eb, nopw)) + assert.Empty(t, eb.String()) + _, _, curve, err := cert.UnmarshalPrivateKeyFromPEM(ob.Bytes()) + require.NoError(t, err) + assert.Equal(t, cert.Curve_CURVE25519, curve) + + // in-pub on stdin (caller already has a keypair, only the cert is generated) + inPub, _ := x25519Keypair() + rawInPub := cert.MarshalPublicKeyToPEM(cert.Curve_CURVE25519, inPub) + + withStdin(t, bytes.NewReader(rawInPub)) + os.Remove(crtF.Name()) + ob.Reset() + eb.Reset() + args = []string{"-version", "1", "-ca-crt", caCrtF.Name(), "-ca-key", caKeyF.Name(), "-name", "in-pub-test", "-ip", "1.1.1.1/24", "-in-pub", "-", "-out-crt", "-", "-duration", "100m"} + require.NoError(t, signCert(args, ob, eb, nopw)) + assert.Empty(t, eb.String()) + stdinCrt, _, err := cert.UnmarshalCertificateFromPEM(ob.Bytes()) + require.NoError(t, err) + assert.Equal(t, "in-pub-test", stdinCrt.Name()) + assert.Equal(t, inPub, stdinCrt.PublicKey()) } diff --git a/cmd/nebula-cert/stdio.go b/cmd/nebula-cert/stdio.go new file mode 100644 index 00000000..3f71d52f --- /dev/null +++ b/cmd/nebula-cert/stdio.go @@ -0,0 +1,117 @@ +package main + +import ( + "fmt" + "io" + "os" +) + +// stdioPath is the special path value that selects stdin (for inputs) or +// stdout (for outputs) instead of a file on disk. +const stdioPath = "-" + +// stdioHelpText is rendered just under the Usage line of each subcommand +// help so the - convention is documented once instead of on every flag. +const stdioHelpText = " Pass \"-\" to any path flag to read from stdin or write to stdout.\n" + +// stdinReader is the source used when an input flag is set to "-". +// It is a package level var so tests can swap in a deterministic reader. +// Tests that mutate stdinReader cannot run with t.Parallel(). +var stdinReader io.Reader = os.Stdin + +// ioClaims tracks which flags have claimed stdin and stdout during a single +// command invocation so we can refuse a second flag asking for the same +// stream. +type ioClaims struct { + in string + out string +} + +func (c *ioClaims) claimIn(flagName string) error { + if c.in != "" && c.in != flagName { + return fmt.Errorf("-%s and -%s both set to %q, only one input may read from stdin", c.in, flagName, stdioPath) + } + c.in = flagName + return nil +} + +func (c *ioClaims) claimOut(flagName string) error { + if c.out != "" && c.out != flagName { + return fmt.Errorf("-%s and -%s both set to %q, only one output may write to stdout", c.out, flagName, stdioPath) + } + c.out = flagName + return nil +} + +// reserveInputs walks alternating (flagName, path) pairs and claims stdin +// for any path equal to stdioPath. It must be called before any input is +// read so a conflict can be reported immediately instead of blocking on +// io.ReadAll while waiting for input that will never arrive. +func reserveInputs(claims *ioClaims, pairs ...string) error { + return reserveStdio(claims, "reserveInputs", (*ioClaims).claimIn, pairs) +} + +// reserveOutputs walks alternating (flagName, path) pairs and claims stdout +// for any path equal to stdioPath. It must be called before any output is +// written so a conflict cannot leave one stream half written before the +// second flag fails. +func reserveOutputs(claims *ioClaims, pairs ...string) error { + return reserveStdio(claims, "reserveOutputs", (*ioClaims).claimOut, pairs) +} + +func reserveStdio(claims *ioClaims, who string, claim func(*ioClaims, string) error, pairs []string) error { + if len(pairs)%2 != 0 { + panic(who + " requires alternating name, path pairs") + } + for i := 0; i < len(pairs); i += 2 { + name, path := pairs[i], pairs[i+1] + if path != stdioPath { + continue + } + if err := claim(claims, name); err != nil { + return err + } + } + return nil +} + +// readInput returns the bytes referenced by path, reading from stdin when +// path is stdioPath. +func readInput(flagName, path string, claims *ioClaims) ([]byte, error) { + if path == stdioPath { + if err := claims.claimIn(flagName); err != nil { + return nil, err + } + return io.ReadAll(stdinReader) + } + return os.ReadFile(path) +} + +// openInput returns a reader for path. When path is stdioPath the returned +// reader wraps stdin and Close is a no-op. +func openInput(flagName, path string, claims *ioClaims) (io.ReadCloser, error) { + if path == stdioPath { + if err := claims.claimIn(flagName); err != nil { + return nil, err + } + return io.NopCloser(stdinReader), nil + } + return os.Open(path) +} + +// writeOutput writes data to path, or to stdout when path is stdioPath. perm +// is only used for file output. The caller must have already claimed stdout +// via reserveOutputs before invoking with stdioPath. +func writeOutput(path string, data []byte, perm os.FileMode, stdout io.Writer) error { + if path == stdioPath { + _, err := stdout.Write(data) + return err + } + return os.WriteFile(path, data, perm) +} + +// isStdio reports whether path is the stdio sentinel and so should skip +// existence checks like "refuse to overwrite". +func isStdio(path string) bool { + return path == stdioPath +} diff --git a/cmd/nebula-cert/stdio_test.go b/cmd/nebula-cert/stdio_test.go new file mode 100644 index 00000000..31c0297d --- /dev/null +++ b/cmd/nebula-cert/stdio_test.go @@ -0,0 +1,167 @@ +package main + +import ( + "bytes" + "io" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// withStdin temporarily replaces stdinReader for the duration of t. +func withStdin(t *testing.T, r io.Reader) { + t.Helper() + prev := stdinReader + stdinReader = r + t.Cleanup(func() { stdinReader = prev }) +} + +func Test_readInput_stdin(t *testing.T) { + withStdin(t, bytes.NewBufferString("hello")) + var claims ioClaims + + got, err := readInput("path", "-", &claims) + require.NoError(t, err) + assert.Equal(t, []byte("hello"), got) + assert.Equal(t, "path", claims.in) +} + +func Test_readInput_file(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "f") + require.NoError(t, os.WriteFile(p, []byte("file"), 0600)) + var claims ioClaims + + got, err := readInput("path", p, &claims) + require.NoError(t, err) + assert.Equal(t, []byte("file"), got) + assert.Equal(t, "", claims.in) +} + +func Test_readInput_doubleStdinErrors(t *testing.T) { + withStdin(t, bytes.NewBufferString("hello")) + var claims ioClaims + + _, err := readInput("ca-key", "-", &claims) + require.NoError(t, err) + + _, err = readInput("ca-crt", "-", &claims) + require.EqualError(t, err, `-ca-key and -ca-crt both set to "-", only one input may read from stdin`) +} + +func Test_openInput_stdin(t *testing.T) { + withStdin(t, bytes.NewBufferString("hi")) + var claims ioClaims + + r, err := openInput("ca", "-", &claims) + require.NoError(t, err) + defer r.Close() + b, err := io.ReadAll(r) + require.NoError(t, err) + assert.Equal(t, []byte("hi"), b) +} + +func Test_openInput_doubleStdinErrors(t *testing.T) { + withStdin(t, bytes.NewBufferString("hi")) + var claims ioClaims + + r, err := openInput("ca", "-", &claims) + require.NoError(t, err) + r.Close() + + _, err = openInput("crt", "-", &claims) + require.EqualError(t, err, `-ca and -crt both set to "-", only one input may read from stdin`) +} + +func Test_writeOutput_stdout(t *testing.T) { + out := &bytes.Buffer{} + + err := writeOutput("-", []byte("payload"), 0600, out) + require.NoError(t, err) + assert.Equal(t, "payload", out.String()) +} + +func Test_writeOutput_file(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "f") + out := &bytes.Buffer{} + + err := writeOutput(p, []byte("payload"), 0600, out) + require.NoError(t, err) + assert.Empty(t, out.String()) + got, err := os.ReadFile(p) + require.NoError(t, err) + assert.Equal(t, []byte("payload"), got) +} + +func Test_reserveOutputs_noConflict(t *testing.T) { + var claims ioClaims + require.NoError(t, reserveOutputs(&claims, + "out-key", "/tmp/key", + "out-crt", "-", + "out-qr", "", + )) + assert.Equal(t, "out-crt", claims.out) +} + +func Test_reserveOutputs_conflict(t *testing.T) { + var claims ioClaims + err := reserveOutputs(&claims, + "out-key", "-", + "out-crt", "-", + ) + require.EqualError(t, err, `-out-key and -out-crt both set to "-", only one output may write to stdout`) +} + +func Test_reserveOutputs_panicsOnOddPairs(t *testing.T) { + defer func() { + r := recover() + require.NotNil(t, r) + }() + var claims ioClaims + _ = reserveOutputs(&claims, "out-key") +} + +func Test_reserveInputs_noConflict(t *testing.T) { + var claims ioClaims + require.NoError(t, reserveInputs(&claims, + "ca-key", "/tmp/ca.key", + "ca-crt", "-", + "in-pub", "", + )) + assert.Equal(t, "ca-crt", claims.in) +} + +func Test_reserveInputs_conflict(t *testing.T) { + var claims ioClaims + err := reserveInputs(&claims, + "ca-key", "-", + "ca-crt", "-", + ) + require.EqualError(t, err, `-ca-key and -ca-crt both set to "-", only one input may read from stdin`) +} + +func Test_claimIn_idempotent(t *testing.T) { + // pre-claim then a lazy re-claim of the same flag should be a no-op + var claims ioClaims + require.NoError(t, claims.claimIn("ca-key")) + require.NoError(t, claims.claimIn("ca-key")) + assert.Equal(t, "ca-key", claims.in) +} + +func Test_claimOut_idempotent(t *testing.T) { + var claims ioClaims + require.NoError(t, claims.claimOut("out-crt")) + require.NoError(t, claims.claimOut("out-crt")) + assert.Equal(t, "out-crt", claims.out) +} + +func Test_isStdio(t *testing.T) { + assert.True(t, isStdio("-")) + assert.False(t, isStdio("")) + assert.False(t, isStdio("./-")) + assert.False(t, isStdio("foo")) +} diff --git a/cmd/nebula-cert/verify.go b/cmd/nebula-cert/verify.go index 36258dd8..76d3dbe6 100644 --- a/cmd/nebula-cert/verify.go +++ b/cmd/nebula-cert/verify.go @@ -39,18 +39,26 @@ func verify(args []string, out io.Writer, errOut io.Writer) error { return err } - caFile, err := os.Open(*vf.caPath) + var claims ioClaims + if err := reserveInputs(&claims, + "ca", *vf.caPath, + "crt", *vf.certPath, + ); err != nil { + return err + } + + caReader, err := openInput("ca", *vf.caPath, &claims) if err != nil { return fmt.Errorf("error while reading ca: %w", err) } - defer caFile.Close() + defer caReader.Close() - caPool, err := cert.NewCAPoolFromPEMReader(caFile) + caPool, err := cert.NewCAPoolFromPEMReader(caReader) if err != nil && !errors.Is(err, cert.ErrExpired) { return fmt.Errorf("error while adding ca cert to pool: %w", err) } - rawCert, err := os.ReadFile(*vf.certPath) + rawCert, err := readInput("crt", *vf.certPath, &claims) if err != nil { return fmt.Errorf("unable to read crt: %w", err) } @@ -85,6 +93,7 @@ func verifySummary() string { func verifyHelp(out io.Writer) { vf := newVerifyFlags() _, _ = out.Write([]byte("Usage of " + os.Args[0] + " " + verifySummary() + "\n")) + _, _ = out.Write([]byte(stdioHelpText)) vf.set.SetOutput(out) vf.set.PrintDefaults() } diff --git a/cmd/nebula-cert/verify_test.go b/cmd/nebula-cert/verify_test.go index 1aa5e8e6..aa089d0e 100644 --- a/cmd/nebula-cert/verify_test.go +++ b/cmd/nebula-cert/verify_test.go @@ -23,6 +23,7 @@ func Test_verifyHelp(t *testing.T) { assert.Equal( t, "Usage of "+os.Args[0]+" verify : verifies a certificate isn't expired and was signed by a trusted authority.\n"+ + " Pass \"-\" to any path flag to read from stdin or write to stdout.\n"+ " -ca string\n"+ " \tRequired: path to a file containing one or more ca certificates\n"+ " -crt string\n"+ @@ -122,3 +123,46 @@ func Test_verify(t *testing.T) { assert.Empty(t, eb.String()) require.NoError(t, err) } + +func Test_verify_stdio(t *testing.T) { + ob := &bytes.Buffer{} + eb := &bytes.Buffer{} + + caPub, caPriv, _ := ed25519.GenerateKey(rand.Reader) + ca, _ := NewTestCaCert("test-ca", caPub, caPriv, time.Now().Add(time.Hour*-1), time.Now().Add(time.Hour*2), nil, nil, nil) + caPEM, _ := ca.MarshalPEM() + + crt, _ := NewTestCert(ca, caPriv, "test-cert", time.Now().Add(time.Hour*-1), time.Now().Add(time.Hour), nil, nil, nil) + crtPEM, _ := crt.MarshalPEM() + + caFile, err := os.CreateTemp("", "verify-ca") + require.NoError(t, err) + defer os.Remove(caFile.Name()) + caFile.Write(caPEM) + + // crt on stdin, ca on disk + withStdin(t, bytes.NewReader(crtPEM)) + require.NoError(t, verify([]string{"-ca", caFile.Name(), "-crt", "-"}, ob, eb)) + assert.Empty(t, ob.String()) + assert.Empty(t, eb.String()) + + // ca on stdin, crt on disk + certFile, err := os.CreateTemp("", "verify-cert") + require.NoError(t, err) + defer os.Remove(certFile.Name()) + certFile.Write(crtPEM) + + withStdin(t, bytes.NewReader(caPEM)) + ob.Reset() + eb.Reset() + require.NoError(t, verify([]string{"-ca", "-", "-crt", certFile.Name()}, ob, eb)) + assert.Empty(t, ob.String()) + assert.Empty(t, eb.String()) + + // both flags on stdin should error + withStdin(t, bytes.NewReader(caPEM)) + ob.Reset() + eb.Reset() + require.EqualError(t, verify([]string{"-ca", "-", "-crt", "-"}, ob, eb), + `-ca and -crt both set to "-", only one input may read from stdin`) +}