Skip to content

Fix #41: encrypt attachment bytes with the HRBC2 binary envelope - #89

Merged
cloudmanic merged 2 commits into
mainfrom
issue-41-encrypted-attachments
Aug 7, 2026
Merged

Fix #41: encrypt attachment bytes with the HRBC2 binary envelope#89
cloudmanic merged 2 commits into
mainfrom
issue-41-encrypted-attachments

Conversation

@cloudmanic

@cloudmanic cloudmanic commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Closes #41

harbor files upload --encrypted stamped the resource is_encrypted and uploaded the file in the clear. The flag was a lie the user could tell the server. This implements the real thing.

What changed

crypto — the binary envelope. SealBytes / OpenBytes / IsBinaryEnvelope:

"HRBC2" (5 ASCII) ‖ iv (12) ‖ ciphertext ‖ tag (16)      // raw bytes

AES-256-GCM under the master key, no AAD. That is the decisive interop decision the issue asked to finalize, and it is not a fresh choice — web, macOS/iOS, Android and Windows all pass no AAD here, because an attachment's integrity is bound instead by its content address, which the server computes as sha256 over the whole envelope. Binding AAD would make CLI-written files unreadable on all four. Now pinned in crypto/README.md.

files upload --encrypted reads the file, seals it, and uploads the envelope. It fails closed without HARBOR_PASSPHRASE — uploading plaintext while stamping it encrypted is worse than not uploading. Filename and MIME are resolved from the original file before sealing (sniffing the envelope would record everything as octet-stream) and stay plaintext on the record, matching the other clients.

files download sniffs the leading bytes for the magic and decrypts transparently. It sniffs rather than trusting metadata because the presigned-download path returns no is_encrypted field — and sniffing is what the other clients do. Plaintext blobs still stream; only an envelope is buffered, which GCM requires anyway since the tag is at the end.

With no passphrase it refuses rather than writing ciphertext into a file the user thinks is their document. The issue's acceptance criteria allowed "ciphertext (or a clear, non-failing message)", so this is a deliberate deviation, recorded on #41.

To be accurate about parity: web and macOS/iOS refuse; Android and Windows do not — Android fires ACTION_VIEW at the presigned URL so the browser saves the raw envelope, and Windows' in-note save path writes ciphertext even when unlocked. Those read as bugs on those clients rather than a different design, so refusing here matches the intended behaviour rather than diverging from a settled one. --ciphertext is the explicit opt-out for backups and moving bytes between machines.

files check --help now says why --file can never match an encrypted blob (the hash covers ciphertext, and every seal uses a fresh nonce).

On the "or refuse" option in #81

#81 offered a cheaper alternative — make notes encrypt refuse notes with attachments. This PR takes the real path instead, because it builds the primitives that the honest version of #81 needs. Note this PR does not close #81: that issue is about notes encrypt re-encrypting an existing note's attachments (download → re-wrap → re-upload → rewrite embeds → call the swap endpoint). SealBytes/OpenBytes are the foundation for it; the seal-path wiring is still to come.

Verification

Full suite, lint clean:

$ make lint && make test
go fmt ./...
go vet ./...
go test ./... -count=1
?   	github.com/HarborMyNotes/harbor-cli	[no test files]
ok  	github.com/HarborMyNotes/harbor-cli/client	0.259s
ok  	github.com/HarborMyNotes/harbor-cli/cmd	0.981s
ok  	github.com/HarborMyNotes/harbor-cli/config	0.185s
ok  	github.com/HarborMyNotes/harbor-cli/crypto	0.611s

New tests. KATs pin the binary envelope byte for byte (seal and open); a no-AAD test fails if anyone "hardens" SealBytes by binding AAD; round-trip covers 0/1/15/16/17/4096 bytes and asserts a fresh nonce each time; OpenBytes is pinned to distinguish ErrNotEnvelope ("never encrypted") from ErrDecrypt ("will not open"); and the ≥33-byte sniff minimum is pinned, because reading fewer bytes makes every input answer "not encrypted" — a bug that has shipped on another Harbor client before.

End-to-end against a real server — throwaway account on a disposable environment, isolated HOME, all eight checks passed.

The plaintext is gone from what the server stores:

$ printf 'ATTACHMENT-PLAINTEXT-MARKER-12345\n' > secret.txt
$ harbor files upload secret.txt --encrypted
│ Hash       │ cc2e624daaba082979caa258a8e7408d3fbc7f4b739bac9eeab1402c9cf378a5 │
│ Size       │ 67 B                                                             │
│ Encrypted  │ ✓                                                                │
│ OCR status │ skipped_encrypted                                                │

$ curl -s -H "Authorization: Bearer <redacted>" .../files/cc2e62…/raw -o stored.bin
$ grep -a -c 'ATTACHMENT-PLAINTEXT-MARKER' stored.bin
0                          # not found
$ grep -a -c 'ATTACHMENT-PLAINTEXT-MARKER' secret.txt
1                          # sanity: same grep hits the original

$ strings stored.bin
HRBC2^                     # the only printable run in the whole blob

$ xxd stored.bin | head -2
00000000: 4852 4243 325e f3a5 0186 45cd c27c e35a  HRBC2^....E..|.Z
00000010: 1fc8 516c 47ac 73dd e05c 9a9a 5f95 6364  ..QlG.s..\.._.cd

34 bytes secret.txt · 67 bytes stored.bin      # delta = 33 = 5 + 12 + 16

server hash        : cc2e624daaba082979caa258a8e7408d3fbc7f4b739bac9eeab1402c9cf378a5
sha256(stored.bin) : cc2e624daaba082979caa258a8e7408d3fbc7f4b739bac9eeab1402c9cf378a5   ← equal
sha256(secret.txt) : 9576eeebd59979d311d34082af0623191c64fdf91872562ec97baa8bf3f64d30   ← different

Decrypted by a standalone Python script importing none of this repo's code (Argon2id → unwrap the keystore → AES-GCM open with no AAD):

keystore version : HRBK1  kdf=argon2id mem=65536KiB t=3 p=1
master key       : unwrapped OK, 32 bytes
envelope         : 67 bytes, magic=b'HRBC2', iv=5ef3a5018645cdc27ce35a1f
--- recovered plaintext ---
ATTACHMENT-PLAINTEXT-MARKER-12345
--- end ---
$ cmp recovered.bin secret.txt   → IDENTICAL

Round-trip, refusal, and the opt-out:

$ harbor files download cc2e62… --output roundtrip.txt
Wrote 34 B to roundtrip.txt
$ diff secret.txt roundtrip.txt        # identical

# --- HARBOR_PASSPHRASE now UNSET ---
$ harbor files download cc2e62… --output nope.txt
Error: this file is encrypted and HARBOR_PASSPHRASE is not set, so nothing was written.

  export HARBOR_PASSPHRASE=$(op read "op://Vault/Harbor/passphrase")

Re-run with --ciphertext to write the sealed bytes instead
exit=1
$ ls -l nope.txt
ls: nope.txt: No such file or directory        # nothing written

$ harbor files download cc2e62… --ciphertext --output sealed.bin
This file is encrypted; writing the raw envelope as asked (--ciphertext).
Wrote 67 B to sealed.bin
$ cmp sealed.bin stored.bin            # identical

$ harbor files upload secret.txt --encrypted        # no passphrase
Error: --encrypted needs your encryption passphrase and HARBOR_PASSPHRASE is not set, so nothing was uploaded.
…
Uploading anyway would put the file on the server in the clear while marking it encrypted
exit=1
file count before: 1 → after: 1        # nothing uploaded

Plaintext uploads unchanged — no HRBC2 prefix, stored bytes identical to the original, hash still equals sha256(plaintext), clean round-trip. A 4096-byte random binary round-tripped identical, with the same 33-byte delta.

Behaviour changes worth knowing

  • Encrypted uploads no longer deduplicate. The content hash covers ciphertext and every seal uses a fresh nonce, so uploading the same file twice produces two blobs and consumes two quota slots. Inherent to the design, called out as accepted in Encrypted attachments (HRBC2 binary envelope) #41, and now documented in files check --help.
  • Stored size is 33 bytes larger than the original for encrypted files.
  • A string envelope shares the same 5-byte magic as a binary one, so magic-sniffing cannot tell them apart. They never mix in practice (string envelopes live in note fields, binary ones in blob bytes); pinned in a test so the overlap is a known property rather than a surprise.

Also noted while testing

  • There is no harbor files delete, and DELETE /api/v1/files/<hash> 404s — so an unwanted blob cannot be cleared. Combined with encrypted uploads not deduplicating, repeated encrypted testing burns quota with no way to reclaim it. Out of scope here; worth its own issue.
  • The HARBOR_TOKEN limitations filed as Headless auth: 'whoami' ignores HARBOR_TOKEN, and 'crypto setup' can't run under it at all #88 reproduced exactly as described, and were worked around with a hand-written credentials.json.

'files upload --encrypted' stamped the resource is_encrypted and uploaded
the file in the clear. The flag was a lie the user could tell the server.
It now seals the bytes on this machine first.

- crypto: SealBytes/OpenBytes/IsBinaryEnvelope for the binary envelope
  ('HRBC2' magic + iv + ciphertext + tag), AES-256-GCM under the master
  key with NO AAD - matching web, macOS/iOS, Android and Windows, whose
  integrity binding is the sha256 content address over the ciphertext.
  Pins the first binary known-answer vector, computed independently.
- files upload --encrypted: reads the file, seals it, uploads the
  envelope. Fails closed without HARBOR_PASSPHRASE rather than shipping
  plaintext under an encrypted label. Filename and MIME are resolved from
  the original and stay plaintext, as on every other client.
- files download: sniffs the magic (the presigned path returns no
  is_encrypted field) and decrypts transparently. Plaintext blobs still
  stream rather than buffer. With no passphrase it refuses instead of
  writing unusable ciphertext, matching the other four clients;
  --ciphertext is the explicit opt-out.
- files check --help: says why --file can never match an encrypted blob.
- crypto/README.md: pins the finalized attachment AAD rule (none) and the
  33-byte sniff minimum.
… regression test

- decryptDownload's comment said all four other clients refuse to hand
  over bytes they cannot read. Only web and macOS/iOS do: Android opens
  the presigned URL in a browser, and Windows' in-note save path writes
  ciphertext even when unlocked. Reworded to say what is actually true and
  why refusing is still the parity-correct choice. Comments in this repo
  get read as contract facts, so a wrong one is worse than none.
- Extract uploadEncrypted so a test can point it at a mock server and
  assert the multipart body carries the envelope and NOT the plaintext.
  Nothing covered that wiring: swapping the call back to UploadFile would
  have restored the original bug with every test still green. Verified the
  new test fails when the plaintext upload is reintroduced.
- filesKey: cover the no-keystore branch and the happy path.
- A plaintext file starting with the ASCII bytes 'HRBC2' sniffs as an
  envelope and cannot decrypt; that error now names --ciphertext instead
  of only insisting the key is wrong.
- Downgrade the 'shipped twice' claim to the one occurrence verified.
@cloudmanic
cloudmanic merged commit 46eeaf5 into main Aug 7, 2026
1 check passed
@cloudmanic
cloudmanic deleted the issue-41-encrypted-attachments branch August 7, 2026 02:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant