This is definition-of-done item 2 of the security review #73. Severity of the gap: low to medium; the compress, encrypt, hash writer and its reader have no negative test at all.
What exists today
No test in internal/blobgen calls NewReader. Reader is exercised once, indirectly, with a 48-byte input (internal/vaultik/blob_fetch_hash_test.go).
Writer.Sum256 equals SHA256(SHA256(input)) for one 1 MiB input; same input twice gives the same hash and different ciphertext (writer_test.go).
CompressStream and CompressData tests assert only no error and non-empty output; the output is never decrypted and the hash value never checked. TestCompressStreamEmptyInput does not assert written == 0 or that the output decrypts to empty.
internal/types has no test file. internal/crypto tests cover only Encryptor, which production never calls.
Order
Do #151 first: it deletes internal/crypto and the unused secret types, which removes their test obligations. If the owner keeps internal/crypto, apply the same wrong-identity, truncation, corruption and size cases to Decryptor.Decrypt and DecryptStream.
Two cases fail today and belong to the issues that fix them, as their regression tests. Do not commit them here skipped or weakened:
a stream cut exactly after the age header and nonce: #152
a secret key passed as a recipient must not appear in the error: #153
Tests to add in internal/blobgen (package blobgen_test, identities from age.GenerateX25519Identity, table-driven)
Writer to Reader round trip for sizes 0, 1, 65535, 65536, 65537 and 4*65536+123, once with random data (so the compressed payload crosses 64 KiB age segments) and once with compressible data; output equals input.
For each size: Reader.Sum256 == SHA256(output); the second hash of it equals Writer.Sum256; BytesRead == BytesWritten == len(input).
Zero-length input through Writer with no Write, and through CompressStream and CompressData if they are kept: size 0, hash equals hex(SHA256(SHA256(""))), output reads back as empty with no error.
NewReader with a different identity than the recipient: errors.As*age.NoIdentityMatchError.
Two recipients: each identity reads the blob back.
Truncation of a multi-segment blob: inside the age header; inside the nonce; inside the first segment; exactly at the end of the first 64 KiB+16 segment; last byte removed. Each gives a non-nil error from NewReader or io.ReadAll, never data with a nil error.
One flipped byte in a header stanza, the header MAC line, the nonce, a body segment and the final 16-byte tag: each errors.
Bytes appended after a valid blob; empty input; random garbage: each errors.
NewWriter with level 0, -1 and 20: errors.Is ErrInvalidCompressionLevel and nothing written to the destination. Levels 1 and 19 round trip.
NewWriter with nil and empty recipient lists, and with an invalid recipient string: error.
A destination writer that fails: the error surfaces from Write or Close.
In internal/vaultik: a validly encrypted blob with different content stored under another blob's name makes verify --deep fail with errBlobHashMismatch.
Reword or remove the two test messages that say a single hash "would allow content confirmation attacks" (writer_test.go:63-66); the double hash does not prevent that.
Tests to add in internal/types/types_test.go
For FileID and BlobID: Value then Scan returns the same id; Scan of string and of []byte; Scan of NULL gives the zero id; Scan of a non-string type and of malformed text returns an error; ParseFileID and ParseBlobID accept and reject cases; IsZero. Leave the alternate text forms uuid.Parse accepts out: no behaviour has been decided for them and rows written by Value are always canonical.
Definition of done
Every numbered case above is covered; no existing assertion weakened; no test skipped.
make check green.
Restore-level corrupt and truncated blob scenarios are #72; do not duplicate them.
Line numbers are as of next at 6fcd8e1.
model: fable-5-1
This is definition-of-done item 2 of the security review https://git.eeqj.de/sneak/vaultik/issues/73. Severity of the gap: **low to medium**; the compress, encrypt, hash writer and its reader have no negative test at all.
## What exists today
- No test in `internal/blobgen` calls `NewReader`. `Reader` is exercised once, indirectly, with a 48-byte input (`internal/vaultik/blob_fetch_hash_test.go`).
- `Writer.Sum256` equals `SHA256(SHA256(input))` for one 1 MiB input; same input twice gives the same hash and different ciphertext (`writer_test.go`).
- `CompressStream` and `CompressData` tests assert only no error and non-empty output; the output is never decrypted and the hash value never checked. `TestCompressStreamEmptyInput` does not assert `written == 0` or that the output decrypts to empty.
- `internal/types` has no test file. `internal/crypto` tests cover only `Encryptor`, which production never calls.
## Order
Do https://git.eeqj.de/sneak/vaultik/issues/151 first: it deletes `internal/crypto` and the unused secret types, which removes their test obligations. If the owner keeps `internal/crypto`, apply the same wrong-identity, truncation, corruption and size cases to `Decryptor.Decrypt` and `DecryptStream`.
Two cases fail today and belong to the issues that fix them, as their regression tests. Do not commit them here skipped or weakened:
- a stream cut exactly after the age header and nonce: https://git.eeqj.de/sneak/vaultik/issues/152
- a secret key passed as a recipient must not appear in the error: https://git.eeqj.de/sneak/vaultik/issues/153
## Tests to add in `internal/blobgen` (package `blobgen_test`, identities from `age.GenerateX25519Identity`, table-driven)
1. Writer to Reader round trip for sizes 0, 1, 65535, 65536, 65537 and 4*65536+123, once with random data (so the compressed payload crosses 64 KiB age segments) and once with compressible data; output equals input.
2. For each size: `Reader.Sum256 == SHA256(output)`; the second hash of it equals `Writer.Sum256`; `BytesRead == BytesWritten == len(input)`.
3. Zero-length input through `Writer` with no `Write`, and through `CompressStream` and `CompressData` if they are kept: size 0, hash equals `hex(SHA256(SHA256("")))`, output reads back as empty with no error.
4. `NewReader` with a different identity than the recipient: `errors.As` `*age.NoIdentityMatchError`.
5. Two recipients: each identity reads the blob back.
6. Truncation of a multi-segment blob: inside the age header; inside the nonce; inside the first segment; exactly at the end of the first 64 KiB+16 segment; last byte removed. Each gives a non-nil error from `NewReader` or `io.ReadAll`, never data with a nil error.
7. One flipped byte in a header stanza, the header MAC line, the nonce, a body segment and the final 16-byte tag: each errors.
8. Bytes appended after a valid blob; empty input; random garbage: each errors.
9. `NewWriter` with level 0, -1 and 20: `errors.Is ErrInvalidCompressionLevel` and nothing written to the destination. Levels 1 and 19 round trip.
10. `NewWriter` with nil and empty recipient lists, and with an invalid recipient string: error.
11. A destination writer that fails: the error surfaces from `Write` or `Close`.
12. In `internal/vaultik`: a validly encrypted blob with different content stored under another blob's name makes `verify --deep` fail with `errBlobHashMismatch`.
Reword or remove the two test messages that say a single hash "would allow content confirmation attacks" (`writer_test.go:63-66`); the double hash does not prevent that.
## Tests to add in `internal/types/types_test.go`
For `FileID` and `BlobID`: `Value` then `Scan` returns the same id; `Scan` of string and of `[]byte`; `Scan` of NULL gives the zero id; `Scan` of a non-string type and of malformed text returns an error; `ParseFileID` and `ParseBlobID` accept and reject cases; `IsZero`. Leave the alternate text forms `uuid.Parse` accepts out: no behaviour has been decided for them and rows written by `Value` are always canonical.
## Definition of done
1. Every numbered case above is covered; no existing assertion weakened; no test skipped.
2. `make check` green.
Restore-level corrupt and truncated blob scenarios are https://git.eeqj.de/sneak/vaultik/issues/72; do not duplicate them.
Line numbers are as of `next` at `6fcd8e1`.
model: fable-5-1
Test-only. Added negative and boundary coverage for internal/blobgen (round trips across the 64 KiB age-segment edges with hashes checked by decrypting; wrong identity; truncation and one-byte corruption of a multi-segment blob; trailing/empty/garbage input; compression-level and recipient validation; failing destination) and internal/types (Value/Scan/Parse/IsZero for FileID and BlobID). Reworded the two overstated double-hash messages; no assertion weakened.
Scoped to the two test-file trees per the task brief, so case 12 (verify --deep hash mismatch in internal/vaultik) is not here. The "cut right after the age header and nonce" truncation is left out as the regression case for #152. internal/crypto and CompressStream/CompressData, removed by #151, are skipped. Multi-recipient read-back is already covered by the existing test.
make check green.
Model: opus-4-8
PR: https://git.eeqj.de/sneak/vaultik/pulls/192
Test-only. Added negative and boundary coverage for `internal/blobgen` (round trips across the 64 KiB age-segment edges with hashes checked by decrypting; wrong identity; truncation and one-byte corruption of a multi-segment blob; trailing/empty/garbage input; compression-level and recipient validation; failing destination) and `internal/types` (`Value`/`Scan`/`Parse`/`IsZero` for `FileID` and `BlobID`). Reworded the two overstated double-hash messages; no assertion weakened.
Scoped to the two test-file trees per the task brief, so case 12 (`verify --deep` hash mismatch in `internal/vaultik`) is not here. The "cut right after the age header and nonce" truncation is left out as the regression case for https://git.eeqj.de/sneak/vaultik/issues/152. `internal/crypto` and `CompressStream`/`CompressData`, removed by https://git.eeqj.de/sneak/vaultik/issues/151, are skipped. Multi-recipient read-back is already covered by the existing test.
`make check` green.
Model: opus-4-8
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
This is definition-of-done item 2 of the security review #73. Severity of the gap: low to medium; the compress, encrypt, hash writer and its reader have no negative test at all.
What exists today
internal/blobgencallsNewReader.Readeris exercised once, indirectly, with a 48-byte input (internal/vaultik/blob_fetch_hash_test.go).Writer.Sum256equalsSHA256(SHA256(input))for one 1 MiB input; same input twice gives the same hash and different ciphertext (writer_test.go).CompressStreamandCompressDatatests assert only no error and non-empty output; the output is never decrypted and the hash value never checked.TestCompressStreamEmptyInputdoes not assertwritten == 0or that the output decrypts to empty.internal/typeshas no test file.internal/cryptotests cover onlyEncryptor, which production never calls.Order
Do #151 first: it deletes
internal/cryptoand the unused secret types, which removes their test obligations. If the owner keepsinternal/crypto, apply the same wrong-identity, truncation, corruption and size cases toDecryptor.DecryptandDecryptStream.Two cases fail today and belong to the issues that fix them, as their regression tests. Do not commit them here skipped or weakened:
Tests to add in
internal/blobgen(packageblobgen_test, identities fromage.GenerateX25519Identity, table-driven)Reader.Sum256 == SHA256(output); the second hash of it equalsWriter.Sum256;BytesRead == BytesWritten == len(input).Writerwith noWrite, and throughCompressStreamandCompressDataif they are kept: size 0, hash equalshex(SHA256(SHA256(""))), output reads back as empty with no error.NewReaderwith a different identity than the recipient:errors.As*age.NoIdentityMatchError.NewReaderorio.ReadAll, never data with a nil error.NewWriterwith level 0, -1 and 20:errors.Is ErrInvalidCompressionLeveland nothing written to the destination. Levels 1 and 19 round trip.NewWriterwith nil and empty recipient lists, and with an invalid recipient string: error.WriteorClose.internal/vaultik: a validly encrypted blob with different content stored under another blob's name makesverify --deepfail witherrBlobHashMismatch.Reword or remove the two test messages that say a single hash "would allow content confirmation attacks" (
writer_test.go:63-66); the double hash does not prevent that.Tests to add in
internal/types/types_test.goFor
FileIDandBlobID:ValuethenScanreturns the same id;Scanof string and of[]byte;Scanof NULL gives the zero id;Scanof a non-string type and of malformed text returns an error;ParseFileIDandParseBlobIDaccept and reject cases;IsZero. Leave the alternate text formsuuid.Parseaccepts out: no behaviour has been decided for them and rows written byValueare always canonical.Definition of done
make checkgreen.Restore-level corrupt and truncated blob scenarios are #72; do not duplicate them.
Line numbers are as of
nextat6fcd8e1.model: fable-5-1
PR: #192
Test-only. Added negative and boundary coverage for
internal/blobgen(round trips across the 64 KiB age-segment edges with hashes checked by decrypting; wrong identity; truncation and one-byte corruption of a multi-segment blob; trailing/empty/garbage input; compression-level and recipient validation; failing destination) andinternal/types(Value/Scan/Parse/IsZeroforFileIDandBlobID). Reworded the two overstated double-hash messages; no assertion weakened.Scoped to the two test-file trees per the task brief, so case 12 (
verify --deephash mismatch ininternal/vaultik) is not here. The "cut right after the age header and nonce" truncation is left out as the regression case for #152.internal/cryptoandCompressStream/CompressData, removed by #151, are skipped. Multi-recipient read-back is already covered by the existing test.make checkgreen.Model: opus-4-8