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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,8 @@ API unwraps it. A header whose key ID, label, wrapped DEK, or wrapped length
was changed but still parses is usually refused by the API (`not_found`,
`key_not_allowed`, or `invalid_ciphertext`), which gives exit code 1. A change
that makes the header invalid, such as a label or wrapped DEK that is no
longer UTF-8 or a length out of range, gives exit code 4, as does any change
to the body.
longer UTF-8, a wrapped DEK that is no longer base64, or a length out of
range, gives exit code 4, as does any change to the body.

## Operational notes

Expand Down Expand Up @@ -166,7 +166,7 @@ All integers are big-endian.
| label length | 2 bytes | 0 to 1024 |
| label | variable | UTF-8 |
| wrapped length | 4 bytes | Length of the next field, 1 to 65536 |
| wrapped DEK | variable | The API's `wrapped` string, byte for byte |
| wrapped DEK | variable | The API's `wrapped` value, in the standard padded base64 the API sends |

The body is a sequence of chunks, each the AEAD ciphertext of one plaintext
chunk followed by its 16-byte tag. Every chunk but the last holds exactly
Expand All @@ -187,10 +187,12 @@ go run scripts/probe.go # record live wrap/unwrap shapes, see docs/api-not
```

The unit tests cover the file format, including tamper and header-validation
cases. The client and command tests run against an in-memory mock of the API
cases. The command tests run against an in-memory mock of the API
built with `net/http/httptest`, selected with `RYPT_API_URL`. The integration
tests build the binary, run full round trips against the live API, and skip
when `RYPT_TOKEN` or `RYPT_KEY_ID` is unset. They never print the token.
The API client itself is the [github.com/x6c-co/rypt](https://github.com/x6c-co/rypt)
module, which has its own tests.

[docs/api-notes.md](docs/api-notes.md) records the observed behaviour of the
`wrap` and `unwrap` endpoints.
Expand Down
26 changes: 17 additions & 9 deletions docs/api-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,17 @@ Response `200`, when no `dek` was sent:
not document its internal structure. The length can differ on other tiers
(`free` and `wrapped` keys use a rypt-held data key under a shared Cloud
KMS root), so the program treats `wrapped` as an opaque string. (openapi)
- Program decision: store `wrapped` byte for byte as UTF-8, never decode it,
and refuse a `wrap` response whose `wrapped` is empty or longer than 65,536
bytes, the header's limit. The observed 188 characters are far below that.
- Program decision: store `wrapped` in the header as the standard padded
base64 the API sends, and refuse a `wrap` response whose `wrapped` is empty
or longer than 65,536 bytes, the header's limit. The observed 188
characters are far below that. The client library,
[github.com/x6c-co/rypt](https://github.com/x6c-co/rypt), hands `wrapped`
over as decoded bytes, so the program encodes it again for the header and
decodes the header's value before `unwrap`. An encoder writes one
standard padded base64 string for each byte string, so the header holds
the API's string exactly. A header whose `wrapped` value is not standard padded base64
cannot have come from the API, and is refused with exit code 4 before any
API call.

## unwrap

Expand Down Expand Up @@ -164,25 +172,25 @@ Status codes for `wrap` and `unwrap`:

- No `retry-after` header was observed on any response. (live)
- Responses from Cloudflare in front of the service (for example a 502 or
504) may not carry the JSON envelope. The client falls back to the
504) may not carry the JSON envelope. The client library falls back to the
`x-request-id` header and the HTTP status. (inference; not observed)

## Consequences for the program

- A tampered label or `wrapped` DEK in a file header is refused by `unwrap`
with `400 invalid_ciphertext`, and a tampered key ID with `404 not_found`
(or `403 key_not_allowed` for an API token restricted to particular keys).
(The OpenAPI description also lists `400 invalid_base64`, which a `wrapped`
value that is no longer base64 would presumably get.) A changed
A `wrapped` value that is no longer base64 never reaches the API: the
program refuses it locally, exit code 4. A changed
`wrapped` length that still parses is usually refused the same way. These reach the
program as API refusals, exit code 1, before any chunk is decrypted. A
change that makes the header fail local validation (a label or `wrapped`
value that is no longer UTF-8, or a length out of range) is exit code 4
value that is no longer UTF-8 or base64, or a length out of range) is exit code 4
with no API call. With the correct DEK, every one of these changes also
fails chunk authentication, because the whole header is the chunk
associated data; the format tests check every header bit that way.
- The client retries a connection error and HTTP 502, 503, and 504, at most
twice. It does not retry a TLS certificate or protocol error, which would
- The client library retries a connection error and HTTP 502, 503, and 504
on `wrap` and `unwrap`, at most twice. It does not retry a TLS certificate or protocol error, which would
fail the same way again, or any other status.
- Rotating a key so that the version a `wrapped` value names is no longer
enabled makes `unwrap` return `400 invalid_ciphertext`. (openapi)
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module github.com/x6c-co/rypt-cli
go 1.27.0

require (
github.com/x6c-co/rypt v0.1.0
golang.org/x/crypto v0.58.0
golang.org/x/sys v0.49.0
golang.org/x/term v0.47.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
github.com/x6c-co/rypt v0.1.0 h1:Te7nRwj4bHQHjLQKjqm3LBP0MlEgHzBe0V3dkBkc6Yk=
github.com/x6c-co/rypt v0.1.0/go.mod h1:CdxA8wPiJuR0J/k1h+H5UTdoaoLYJOWmm7HhVtyWFk0=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
Expand Down
30 changes: 19 additions & 11 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import (
"errors"
"fmt"
"io"
"strings"
"time"

"github.com/x6c-co/rypt-cli/internal/client"
"github.com/x6c-co/rypt"

"github.com/x6c-co/rypt-cli/internal/format"
)

Expand Down Expand Up @@ -116,7 +118,7 @@ func ioError(err error) error {
// exitCode maps an error to its exit code.
func exitCode(err error) int {
var ee *exitError
var ce *client.Error
var ce *rypt.Error
switch {
case err == nil:
return ExitOK
Expand Down Expand Up @@ -186,8 +188,10 @@ func Run(ctx context.Context, env Env) int {
// Tests set it to avoid sleeping.
var clientBackoff func(retry int) time.Duration

// newClient returns an API client configured from the environment.
func newClient(env Env) (*client.Client, error) {
// newClient returns an API client configured from the environment. It makes
// at most three attempts at each call, retrying only what is safe to repeat;
// wrap and unwrap always are.
func newClient(env Env) (*rypt.Client, error) {
token := env.Getenv("RYPT_TOKEN")
if token == "" {
return nil, usageErrorf("RYPT_TOKEN is not set")
Expand All @@ -197,14 +201,18 @@ func newClient(env Env) (*client.Client, error) {
return nil, usageErrorf("RYPT_TOKEN contains a character that is not printable ASCII")
}
}
c, err := client.New(client.Options{
BaseURL: env.Getenv("RYPT_API_URL"),
Token: token,
UserAgent: "rypt-cli/" + env.Version,
Backoff: clientBackoff,
})
opts := []rypt.Option{
rypt.WithBaseURL(env.Getenv("RYPT_API_URL")),
rypt.WithUserAgent("rypt-cli/" + env.Version),
rypt.WithMaxAttempts(3),
}
if clientBackoff != nil {
opts = append(opts, rypt.WithBackoff(clientBackoff))
}
c, err := rypt.NewClient(token, opts...)
if err != nil {
return nil, usageErrorf("RYPT_API_URL: %v", err)
// The token was checked above, so the base URL is what is wrong.
return nil, usageErrorf("RYPT_API_URL: %s", strings.TrimPrefix(err.Error(), "rypt: "))
}
return c, nil
}
11 changes: 10 additions & 1 deletion internal/cli/decrypt.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cli

import (
"context"
"encoding/base64"
"errors"
"fmt"
"io"
Expand Down Expand Up @@ -71,7 +72,15 @@ func runDecrypt(ctx context.Context, env Env, opts *options) error {
if len(h.Label) > 0 {
aad = h.Label
}
dek, err := api.Unwrap(ctx, format.FormatKeyID(h.KeyID), string(h.Wrapped), aad)
// A wrapped DEK that is not base64 cannot have come from the API: the
// header is damaged, and no operation is spent finding that out.
// The decoder skips line breaks, so a field of only line breaks decodes
// to nothing.
wrapped, err := base64.StdEncoding.AppendDecode(nil, h.Wrapped)
if err != nil || len(wrapped) == 0 {
return fmt.Errorf("%s: the wrapped data key in the header is not base64: %w", describe(opts.input), format.ErrIntegrity)
}
dek, err := api.Unwrap(ctx, format.FormatKeyID(h.KeyID), wrapped, aad)
if err != nil {
return fmt.Errorf("unwrap: %w", err)
}
Expand Down
9 changes: 6 additions & 3 deletions internal/cli/encrypt.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cli

import (
"context"
"encoding/base64"
"fmt"
"io"
"unicode/utf8"
Expand Down Expand Up @@ -87,22 +88,24 @@ func runEncrypt(ctx context.Context, env Env, opts *options) (err error) {
if opts.label != "" {
label = []byte(opts.label)
}
wrapped, err := api.Wrap(ctx, format.FormatKeyID(keyID), label)
dk, err := api.GenerateDataKey(ctx, format.FormatKeyID(keyID), label)
if err != nil {
return fmt.Errorf("wrap: %w", err)
}
dek := wrapped.DEK
dek := dk.Plaintext
defer clear(dek)
if len(dek) != format.KeySize {
return &exitError{code: ExitAPI, err: fmt.Errorf("wrap: the API returned a %d-byte data key, not %d", len(dek), format.KeySize)}
}

// The header holds the wrapped DEK as the API sends it: standard padded
// base64.
h := &format.Header{
Cipher: cipher,
ChunkSize: format.DefaultChunkSize,
KeyID: keyID,
Label: label,
Wrapped: []byte(wrapped.Wrapped),
Wrapped: base64.StdEncoding.AppendEncode(nil, dk.Wrapped),
}
if _, err := h.Marshal(); err != nil {
// The client bounds the wrapped DEK, so this means the API returned a
Expand Down
4 changes: 4 additions & 0 deletions internal/cli/integrity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,10 @@ func TestHeaderRejected(t *testing.T) {
return ct
}},
{"wrapped invalid UTF-8", rebuild(func(h *specHeader) { h.Wrapped = append([]byte("CiQA"), 0xc3, 0x28) })},
// The API sends wrapped as base64, so anything else is damage.
{"wrapped not base64", rebuild(func(h *specHeader) { h.Wrapped = []byte("CiQA!!!!") })},
{"wrapped unpadded base64", rebuild(func(h *specHeader) { h.Wrapped = []byte("CiQAWQ") })},
{"wrapped newline only", rebuild(func(h *specHeader) { h.Wrapped = []byte("\n") })},
{"wrapped length past the end", func(ct []byte, hdr specHeader) []byte {
n := hdr.offWrappedLen()
binary.BigEndian.PutUint32(ct[n:], 60000)
Expand Down
Loading
Loading