Skip to content

Commit b80934a

Browse files
committed
Overhaul the notion of a bespoke FIPS-140 mode.
This commit adopts Go 1.24's native `fips140.Enabled()` support to make runtime algorithm choices and formally deprecates its own `FIPSMode`. In the process the module also gets significantly less complex: we no longer use build tags and conditional compilation to identify FIPS builds, and several internal APIs no longer return errors as a result. The `--version` flag now also reports whether FIPS mode is enabled. And we now build special `rskey-fips` binaries and Linux packages against the Go Cryptographic Module, too. Additonal unit tests are included. We also specifically test the code against the Go Cryptographic Module to ensure compliance.
1 parent 5a2086d commit b80934a

File tree

10 files changed

+155
-86
lines changed

10 files changed

+155
-86
lines changed

.goreleaser.yaml

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,36 @@ builds:
1818
ldflags:
1919
- -s -w -X github.com/rstudio/rskey/cmd.Version={{ .Version }}
2020
mod_timestamp: '{{ .CommitTimestamp }}'
21+
- id: rskey-fips
22+
env:
23+
- CGO_ENABLED=0
24+
- GOFIPS140=latest
25+
flags:
26+
- -trimpath
27+
ldflags:
28+
- -s -w -X github.com/rstudio/rskey/cmd.Version={{ .Version }}
29+
mod_timestamp: "{{ .CommitTimestamp }}"
30+
targets:
31+
- linux_amd64
2132
archives:
22-
- files:
33+
- builds:
34+
- rskey
35+
files:
2336
- LICENSE
2437
- README.md
2538
- NOTICE.md
2639
format_overrides:
2740
- goos: windows
2841
formats:
2942
- zip
43+
- id: fips
44+
builds:
45+
- rskey-fips
46+
files:
47+
- LICENSE
48+
- README.md
49+
- NOTICE.md
50+
name_template: "{{ .ProjectName }}-fips_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
3051
blobs:
3152
- provider: s3
3253
bucket: rstudio-platform-public-artifacts
@@ -75,3 +96,27 @@ nfpms:
7596
dst: /usr/share/doc/rskey/README.md
7697
- src: NOTICE.md
7798
dst: /usr/share/doc/rskey/NOTICE.md
99+
- id: fips
100+
package_name: rskey-fips
101+
builds:
102+
- rskey-fips
103+
formats:
104+
- deb
105+
- rpm
106+
conflicts:
107+
- rskey
108+
replaces:
109+
- rskey
110+
section: devel
111+
maintainer: "Posit Software, PBC <[email protected]>"
112+
description: |
113+
A command-line tool that generates secret keys interoperable with the format
114+
used by Posit's Workbench, Connect, and Package Manager products. This is
115+
the FIPS-compliant variant.
116+
contents:
117+
- src: LICENSE
118+
dst: /usr/share/doc/rskey/LICENSE
119+
- src: README.md
120+
dst: /usr/share/doc/rskey/README.md
121+
- src: NOTICE.md
122+
dst: /usr/share/doc/rskey/NOTICE.md

Makefile

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,17 @@ ADDLICENSE = go tool github.com/google/addlicense
99
ADDLICENSE_ARGS = -v -s=only -l=apache -c "Posit Software, PBC" -ignore 'coverage*' -ignore '.github/**' -ignore '.goreleaser.yaml'
1010
NOTICETOOL = go tool go.elastic.co/go-licence-detector
1111

12-
all: rskey
12+
all: rskey rskey-fips
1313

1414
.PHONY: rskey
1515
rskey:
1616
CGO_ENABLED=0 go build -ldflags="$(GO_LDFLAGS)" $(GO_BUILD_ARGS) -o $@ ./$<
1717

18+
.PHONY: rskey-fips
19+
rskey-fips:
20+
CGO_ENABLED=$(CGO_ENABLED) GOFIPS140=latest go build \
21+
-ldflags="$(GO_LDFLAGS)" $(GO_BUILD_ARGS) -o $@ ./$<
22+
1823
.PHONY: static-build
1924
static-build: rskey
2025
ldd $< 2>&1 | grep 'not a dynamic executable'
@@ -25,7 +30,7 @@ check: fmt vet
2530
test:
2631
go test ./... $(GO_BUILD_ARGS) -coverprofile coverage.out
2732
go tool cover -html=coverage.out -o coverage.html
28-
go test ./... $(GO_BUILD_ARGS) -tags "fips" -coverprofile coverage-fips.out
33+
GOFIPS140=latest go test ./... $(GO_BUILD_ARGS) -coverprofile coverage-fips.out
2934
go tool cover -html=coverage-fips.out -o coverage-fips.html
3035

3136
.PHONY: fmt

README.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,22 @@ No local license keys are required, either.
2828
Binary releases for Windows, macOS, and Linux are available [on
2929
GitHub](https://github.com/rstudio/rskey/releases).
3030

31+
We also distribute special `rskey-fips` builds for FIPS 140-3 compliance. These
32+
are compiled against the [Go Cryptographic
33+
Module](https://go.dev/doc/security/fips140).
34+
3135
If you have a local Go toolchain you can also install via `go install`:
3236

3337
``` shell
3438
$ go install github.com/rstudio/rskey@latest
3539
```
3640

41+
or, for a FIPS-compliant version:
42+
43+
``` shell
44+
$ GOFIPS140=v1.0.0 go install github.com/rstudio/rskey@latest
45+
```
46+
3747
Binary releases are signed with [Sigstore](https://www.sigstore.dev/). You can
3848
verify these signatures with their `cosign` tool, for example:
3949

@@ -85,7 +95,7 @@ Package Manager [version 2024.04.0 and
8595
later](https://docs.posit.co/rspm/news/package-manager/#posit-package-manager-2024040)
8696
support an alternative encryption algorithm, AES-256-GCM. This algorithm is an
8797
Approved Security Function under [Federal Information Processing Standard
88-
140](https://csrc.nist.gov/publications/detail/fips/140/3/final) (FIPS), unlike
98+
140-3](https://csrc.nist.gov/publications/detail/fips/140/3/final) (FIPS), unlike
8999
the default.
90100

91101
If you prefer to encrypt secrets using this algorithm and are using this version
@@ -99,6 +109,9 @@ $ rskey encrypt -f connect.key --mode=fips
99109
`rskey decrypt` does not require this flag because the algorithm in use can be
100110
determined from the encrypted output.
101111

112+
When using the special `rskey-fips` builds, FIPS mode is the default, and
113+
attempts to decrypt data encryped with a non-FIPS-140-3 algorithm will fail.
114+
102115
### Workbench
103116

104117
Secret keys for Workbench are [traditionally generated by the `uuid`
@@ -128,8 +141,9 @@ $ rskey encrypt --mode=workbench -f uuid.key
128141
algorithm](https://docs.posit.co/connect/news/#rstudio-connect-2022.03.0),
129142
AES-256-GCM. This algorithm is an Approved Security Function under [Federal
130143
Information Processing Standard
131-
140](https://csrc.nist.gov/publications/detail/fips/140/3/final), and can be
132-
used by passing `--mode=fips` to the `rskey encrypt` command.
144+
140-3](https://csrc.nist.gov/publications/detail/fips/140/3/final), and can be
145+
used by passing `--mode=fips` to the `rskey encrypt` command, or by using the
146+
special `rskey-fips` builds.
133147

134148
* Package Manager version 2024.04.0 and later [supports an identical
135149
setting](https://docs.posit.co/rspm/news/package-manager/#posit-package-manager-2024040).

cmd/root.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package cmd
55

66
import (
7+
"crypto/fips140"
78
"os"
89

910
"github.com/spf13/cobra"
@@ -26,3 +27,9 @@ func Execute() {
2627
os.Exit(1)
2728
}
2829
}
30+
31+
func init() {
32+
if fips140.Enabled() {
33+
rootCmd.Version = rootCmd.Version + " (fips)"
34+
}
35+
}

crypt/fips.go

Lines changed: 0 additions & 20 deletions
This file was deleted.

crypt/fips_test.go

Lines changed: 0 additions & 13 deletions
This file was deleted.

crypt/key.go

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,14 @@
33

44
// Package crypt implements the secret key-based encryption and decryption
55
// scheme used by Posit's Connect and Package Manager products.
6+
//
7+
// When fips140.Enabled() is true, decrypting data that uses non-compliant
8+
// algorithms instead returns an error, and all encryption uses AES-256-GCM by
9+
// default.
610
package crypt
711

812
import (
13+
"crypto/fips140"
914
"crypto/rand"
1015
"crypto/sha256"
1116
"encoding/base64"
@@ -110,31 +115,24 @@ func (k *Key) Encrypt(s string) (string, error) {
110115
return k.EncryptBytes([]byte(s))
111116
}
112117

113-
// EncryptBytes produces base64-encoded cipher text for the given bytes and key,
114-
// or an error if one cannot be created.
118+
// EncryptBytes produces base64-encoded cipher text for the given bytes and key.
119+
// It never returns an error.
115120
func (k *Key) EncryptBytes(bytes []byte) (string, error) {
116-
var output []byte
117-
if FIPSMode {
121+
if fipsMode {
118122
output := k.encryptAES(bytes)
119123
return base64.StdEncoding.EncodeToString(output), nil
120124
}
121-
output, err := k.encryptSecretbox(bytes)
122-
if err != nil {
123-
return "", err
124-
}
125+
output := k.encryptSecretbox(bytes)
125126
return base64.StdEncoding.EncodeToString(output), nil
126127
}
127128

128129
// encryptVersioned produces a base64-encoded cipher text with an embedded
129-
// version for the given payload and key, or an error if one cannot be created.
130-
// This emulates the format used by some implementations.
131-
func (k *Key) encryptVersioned(s string) (string, error) {
132-
output, err := k.encryptSecretbox([]byte(s))
133-
if err != nil {
134-
return "", err
135-
}
130+
// version for the given payload and key. This emulates the format used by some
131+
// implementations.
132+
func (k *Key) encryptVersioned(s string) string {
133+
output := k.encryptSecretbox([]byte(s))
136134
output = append([]byte{1}, output...)
137-
return base64.StdEncoding.EncodeToString(output), nil
135+
return base64.StdEncoding.EncodeToString(output)
138136
}
139137

140138
// Decrypt takes base64-encoded cipher text encrypted with the given key and
@@ -158,15 +156,21 @@ func (k *Key) DecryptBytes(s string) ([]byte, error) {
158156
// handle the (unlikely but possible) case where a versionless payload
159157
// *just happens* to start with a valid version byte, we must also try
160158
// the fallback on error.
159+
//
160+
// Unless we're in FIPS mode, in which case only the AES version is
161+
// available.
162+
if fipsMode && buf[0] != byte(2) {
163+
return []byte{}, ErrFIPS
164+
}
161165
switch buf[0] {
162166
case byte(1):
163167
str, err := k.decryptSecretbox(buf[1:])
164-
if err == nil || FIPSMode {
168+
if err == nil || fipsMode {
165169
return str, err
166170
}
167171
case byte(2):
168172
str, err := k.decryptAES(buf)
169-
if err == nil || FIPSMode {
173+
if err == nil || fipsMode {
170174
return str, err
171175
}
172176
}
@@ -191,3 +195,12 @@ func rotate(data []byte) []byte {
191195
}
192196
return newData
193197
}
198+
199+
// Deprecated. Use fips140.Enabled() instead.
200+
const FIPSMode = false
201+
202+
var fipsMode = false
203+
204+
func init() {
205+
fipsMode = fips140.Enabled()
206+
}

crypt/key_test.go

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
package crypt
55

66
import (
7+
"crypto/fips140"
8+
"encoding/base64"
79
"encoding/hex"
810
"fmt"
911
"strings"
@@ -110,7 +112,7 @@ func (s *KeySuite) TestEncryption(c *check.C) {
110112

111113
// These payloads do not have a the FIPS version prefix, so they will
112114
// generate a different error in FIPS mode.
113-
if !FIPSMode {
115+
if !fips140.Enabled() {
114116
_, err = key.Decrypt("ycKfTfYlVaOnsypb")
115117
c.Check(err, check.Equals, ErrPayLoadTooShort)
116118

@@ -133,7 +135,7 @@ func (s *KeySuite) TestEncryption(c *check.C) {
133135
}
134136

135137
func (s *KeySuite) TestVersionedEncryption(c *check.C) {
136-
if FIPSMode {
138+
if fips140.Enabled() {
137139
c.ExpectFailure("NaCl encryption will not work under FIPS")
138140
}
139141
key, _ := NewKey()
@@ -147,16 +149,14 @@ func (s *KeySuite) TestVersionedEncryption(c *check.C) {
147149
c.Check(err, check.Equals, ErrFailedToDecrypt)
148150

149151
// Roundtrip encryption test.
150-
cipher, err := key.encryptVersioned("some secret")
151-
c.Check(err, check.IsNil)
152+
cipher := key.encryptVersioned("some secret")
152153
c.Check(cipher, check.Not(check.Equals), "some secret") // Just checking.
153154
text, err := key.Decrypt(cipher)
154155
c.Check(err, check.IsNil)
155156
c.Check(text, check.Equals, "some secret")
156157

157158
// Check that nonces actually work.
158-
dupCipher, err := key.encryptVersioned("some secret")
159-
c.Check(err, check.IsNil)
159+
dupCipher := key.encryptVersioned("some secret")
160160
c.Check(dupCipher, check.Not(check.Equals), cipher)
161161
}
162162

@@ -201,6 +201,45 @@ func (s *KeySuite) TestFIPSEncryption(c *check.C) {
201201
dupCipher, err := key.EncryptFIPS("some secret")
202202
c.Check(err, check.IsNil)
203203
c.Check(dupCipher, check.Not(check.Equals), cipher)
204+
205+
// Check that explicitly setting FIPS mode switches the algorithm.
206+
originalMode := fipsMode
207+
fipsMode = true
208+
defer func() { fipsMode = originalMode }()
209+
cipher, err = key.Encrypt("some secret")
210+
c.Check(err, check.IsNil)
211+
buf, _ := base64.StdEncoding.DecodeString(cipher)
212+
c.Check(buf[0], check.Equals, byte(2))
213+
}
214+
215+
func (s *KeySuite) TestFIPSMode(c *check.C) {
216+
originalMode := fipsMode
217+
fipsMode = true
218+
defer func() { fipsMode = originalMode }()
219+
key, _ := NewKey()
220+
221+
// Check that explicitly setting FIPS mode ensures we use AES.
222+
cipher, err := key.Encrypt("some secret")
223+
c.Check(err, check.IsNil)
224+
buf, _ := base64.StdEncoding.DecodeString(cipher)
225+
c.Check(buf[0], check.Equals, byte(2))
226+
227+
// Check that non-AES ciphers cannot be decrypted in this mode.
228+
cipher = key.encryptVersioned("some secret")
229+
_, err = key.Decrypt(cipher)
230+
c.Check(err, check.Equals, ErrFIPS)
231+
}
232+
233+
func (s *KeySuite) TestFIPSCompliance(c *check.C) {
234+
// Verify that non-FIPS algorithms can't be called through the public
235+
// API in this mode.
236+
if !fips140.Enabled() {
237+
c.Skip("skipping FIPS 140-3 compliance tests")
238+
}
239+
key, _ := NewKey()
240+
cipher := key.encryptVersioned("some secret")
241+
_, err := key.Decrypt(cipher)
242+
c.Check(err, check.Equals, ErrFIPS)
204243
}
205244

206245
func (s *KeySuite) TestFingerprint(c *check.C) {

0 commit comments

Comments
 (0)