Skip to content

Fix #86: bind the HRBC2 field AAD with the canonical ':' separator - #87

Merged
cloudmanic merged 3 commits into
mainfrom
issue-86-field-aad-separator
Aug 7, 2026
Merged

Fix #86: bind the HRBC2 field AAD with the canonical ':' separator#87
cloudmanic merged 3 commits into
mainfrom
issue-86-field-aad-separator

Conversation

@cloudmanic

@cloudmanic cloudmanic commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Closes #86

The CLI built the HRBC2 field AAD as utf8(recordID + fieldName). Web, macOS/iOS, Android and Windows all use utf8(recordID + ":" + fieldName). One character, and it meant no note sealed by the CLI could be opened by any other Harbor client, and no note sealed elsewhere could be opened by the CLI.

The fix

crypto/crypto.go — add the separator, and say plainly in the doc comment that this byte sequence is a cross-client contract, not a local choice:

func fieldAAD(recordID, fieldName string) []byte {
	return []byte(recordID + ":" + fieldName)
}

crypto/README.md documented the wrong rule as if it were the contract ("id first, no separator"). Corrected, with a pointer to the canonical web definition.

Why the test suite did not catch it

crypto/crypto_test.go's sealRef helper — described as an independent reference implementation "so the tests prove OpenField conforms to the external contract" — built its expected envelope with []byte(id+field), the same mistake the production code made. It agreed with the bug, so TestOpenField_ReferenceVector passed while being wrong.

So the test change is the substance of this PR, not an afterthought:

  • sealRefAAD takes the AAD explicitly instead of deriving it. It can build a deliberately wrong envelope, which is what the negative tests need, and it can no longer silently inherit a mistake from the code under test.
  • The shared vector is pinned as a hardcoded literal, not recomputed. A literal cannot drift with the implementation.
  • TestFieldAAD_RejectsLegacySeparatorless proves the old format fails both directions, so a "simplified" fieldAAD breaks the build instead of shipping.

Verification

The new tests fail without the fix. Reintroducing the missing separator (verbatim go test ./crypto/ -count=1):

--- FAIL: TestOpenField_ReferenceVector (0.00s)
--- FAIL: TestOpenField_CrossClientVector (0.00s)
--- FAIL: TestSealField_CrossClientVector (0.00s)
--- FAIL: TestSealField_ReadableByCanonicalReader (0.00s)
--- FAIL: TestFieldAAD_RejectsLegacySeparatorless (0.00s)
FAIL
FAIL	github.com/HarborMyNotes/harbor-cli/crypto	0.295s
FAIL

Against the vector shared with harbor-swift (key 00..1f, nonce 00..0b, id note-1, field content, plaintext Secret note body):

shared KAT (swift/web/android/windows): HRBC2.AAECAwQFBgcICQoL.FGe1aaCR4nXiNfKr04YcFISiyIPEgI0uXE_6tfgGWQw
Go with  id:field  -> HRBC2.AAECAwQFBgcICQoL.FGe1aaCR4nXiNfKr04YcFISiyIPEgI0uXE_6tfgGWQw   MATCH: true
Go with  idfield   -> HRBC2.AAECAwQFBgcICQoL.FGe1aaCR4nXiNfKr04YcFE51m3k-lGztdNYH6AGwEZU   MATCH: false

Only the GCM tag differed, which is why the failure mode was a clean "could not decrypt" rather than corrupted output.

Full suite:

$ 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.346s
ok  	github.com/HarborMyNotes/harbor-cli/cmd	1.036s
ok  	github.com/HarborMyNotes/harbor-cli/config	0.260s
ok  	github.com/HarborMyNotes/harbor-cli/crypto	0.687s

End-to-end against a real server, using a throwaway account on a disposable test environment. Created a note, encrypted it with the built binary, then fetched the raw stored record and decrypted it with a standalone Python script that imports none of this repo's code:

$ harbor notes encrypt f376e007-a367-416d-afd0-914ce5368a79
✓ f376e007-a367-416d-afd0-914ce5368a79 encrypted
Encrypted 1 note.

$ curl -s -H "Authorization: Bearer <redacted>" .../notes/f376e007-a367-416d-afd0-914ce5368a79
{
  "id": "f376e007-a367-416d-afd0-914ce5368a79",
  "title": "HRBC2.sD_dzAiPgTkDmpvm.EDgsP2Bn4U0YrCoMZ8t0aV0zsawVvAhCm0dxGKJyix-OknLSqrw",
  "content": "HRBC2.FDAe7vlKzWU7wd9u._TY-EBzrw_doFR24jNsmp6P-L-vTq2ONFbJkGhGAuW2YY9M-6fAM",
  "is_encrypted": true
}

Independent decryption of those stored bytes:

keystore: version=HRBK1 kdf=argon2id mem=65536KiB iters=3 par=1
master key unwrapped: 32 bytes

[1] CANONICAL AAD -- what web / Swift / Android / Windows use
  AAD = b'f376e007-a367-416d-afd0-914ce5368a79:content'
  canonical: SUCCESS -> '<p>Secret note body</p>'

[2] OLD BROKEN CLI AAD -- no colon; must NOT authenticate
  AAD = b'f376e007-a367-416d-afd0-914ce5368a79content'
  legacy: FAILED  -> InvalidTag:

RESULT: PASS - stored bytes are in the canonical cross-client format.

Same result for the title field. And the reverse direction — the CLI reading the canonical format — is covered by the two CrossClient tests above.

Notes

  • No migration path, deliberately. Confirmed with @cloudmanic that nothing has been encrypted with the CLI yet, so there is no legacy data to read and no fallback to carry. A CLI-sealed note from before this commit would not decrypt after it — that is accepted, and it is why this shipped now rather than later.
  • Attachments are unaffected. All four other clients pass no AAD for the binary envelope, so Encrypted attachments (HRBC2 binary envelope) #41 is independent of this change.
  • Keystores and passphrases are unaffected. The key-wrap uses no AAD.
  • This PR also carries an update to the local burndown skill (delegate to subagents to keep the parent context light; append a TL;DR whenever the run pauses). Unrelated to the fix, bundled at Spicer's request.

Incidental issues found while testing

Not fixed here, and none affect this change — flagging so they can be filed if wanted:

  1. harbor whoami ignores HARBOR_TOKEN and reports "not logged in" even when the token works for every other command.
  2. harbor crypto setup cannot run under HARBOR_TOKEN alone — the synthesized credentials have an empty device_id, which sync/push rejects. Blocks headless encryption setup.

The CLI built the field AAD as utf8(recordID + fieldName) while web,
macOS/iOS, Android and Windows all use utf8(recordID + ":" + fieldName).
The ciphertext matched but the GCM tag did not, so every note the CLI
sealed failed authentication on every other client, and vice versa.

Verified against the cross-client known-answer vector pinned in
harbor-swift: Go reproduces it exactly once the separator is present.

- crypto.go: add the separator, and document that the byte sequence is an
  interop contract rather than a local choice.
- crypto_test.go: sealRef reproduced the same wrong AAD as the code under
  test, so the suite guarded the bug instead of catching it. Split out
  sealRefAAD, which takes the AAD explicitly, and pin the shared vector as
  a hardcoded literal. Adds a negative test so a lost separator fails loudly.
- README.md: the documented rule said 'no separator' - corrected, with a
  pointer to the canonical web definition.

Also updates the burndown skill: delegate to subagents to keep the parent
context light, and append a TL;DR whenever the run pauses for input.
The package doc still described the pre-fix AAD as 'no separator' — the
same class of wrong-documentation the issue names as a root cause, and the
more authoritative of the two docs since it is what 'go doc' prints.

- crypto.go: correct the package doc; point fieldAAD at the right pinned
  literal; add sealFieldWithNonce so tests can reproduce the shared vector
  byte for byte, matching how harbor-swift and harbor-windows do it.
- crypto_test.go: TestSealField_CrossClientVector now asserts the sealed
  envelope EQUALS the literal rather than merely decrypting under the
  canonical AAD, which satisfies the acceptance criterion literally. The
  random-nonce path keeps its own test. Pin the legacy no-colon envelope
  as a second literal and use the vector's nonce, so the negative test's
  inequality guard can actually fire — it could not before.
- README.md: name the tests that pin the vector correctly.
@cloudmanic
cloudmanic merged commit fe13273 into main Aug 7, 2026
1 check passed
@cloudmanic
cloudmanic deleted the issue-86-field-aad-separator branch August 7, 2026 01:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CLI-encrypted notes can't be decrypted by any other client — the field AAD is missing its ':' separator

1 participant