Skip to content

P05 type bound value tags - #7

Merged
MuffinUser merged 18 commits into
mainfrom
p05-type-bound-value-tags
Sep 29, 2026
Merged

MuffinUser merged 18 commits into
mainfrom
p05-type-bound-value-tags

Conversation

@MuffinUser

Copy link
Copy Markdown
Owner

No description provided.

MuffinUser and others added 18 commits September 28, 2026 22:50
1024px and 256px PNGs for use as the repo avatar/social preview.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This reverts commit eda7cee.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Independently re-verifies the seven "Hoch" findings (P01-P07) from the
2026-09-29 repository analysis against current source, confirms all
seven, and lays out a phased mitigation plan with effort estimates.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Ranks the seven confirmed High findings into tiers by real-world
impact (silent total plaintext exposure > partial leak > data loss >
conditional local exposure > integrity-only), and groups them into six
work packages (A-F) that share code paths or defensive patterns, with
a recommended shipping order.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Three independent fixes for the same underlying defect class from the
2026-09-29 audit's P07 finding — a registered rule doesn't guarantee git
and nebel agree on what's matched:

- Clean and Smudge now return ErrNoMatchingRule instead of silently
  passing content through when git invokes the filter for a path no
  rule matches. Git only calls this filter for a path .gitattributes
  marks filter=nebel, so a non-match here means .gitattributes and
  .nebel.yaml disagree — most commonly because a bare pattern like
  "*.env" matches recursively in .gitattributes but only at its own
  depth under doublestar. Before this fix, that mismatch let a
  subdirectory file's plaintext reach the git object database
  undetected; filter.nebel.required=true now turns the mismatch into a
  blocked `git add`/commit instead. Verified against a real git repo,
  including confirming the new e2e test fails against the pre-fix code.

- Config.Validate rejects a syntactically invalid glob pattern (via
  doublestar.ValidatePattern) at config-load time, since MatchRule's
  bool-only signature has no room to propagate doublestar.Match's own
  error — an invalid pattern would otherwise silently behave as "never
  matches" instead of surfacing the typo.

- addGitattributesPattern now escapes characters .gitattributes' line
  syntax gives special meaning to (a leading '#' or '!', embedded
  spaces) before writing a pattern, so a filename with a space no
  longer silently gets no effective filter assignment while the add
  command reports success.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A configured dot-notation path can silently resolve to more than one
distinct location in the same document, and Locate's targeted descent
can only ever return one of them:

- P03: a literal key containing a dot ("a.b") collides with the
  equivalent nested path ("a" containing "b") — dot notation can't
  tell them apart, and a dot inside a key can't be escaped (see
  ParsePath's doc comment). Before this fix, Locate always resolved to
  whichever candidate its traversal order reached first, silently
  leaving the other value's secret unencrypted while reporting
  success.

- P04: encoding/json's streaming decoder does not reject a duplicate
  object key the way go-yaml's parser does (confirmed empirically:
  go-yaml's parser.ParseBytes already errors "mapping key ... already
  defined" on a duplicate YAML key, so only JSON needed this part of
  the fix). descendObject returned the first occurrence's span,
  leaving the second — which, per encoding/json's own map-unmarshal
  semantics, most real consumers would treat as the effective value —
  in plaintext.

Both are the same underlying problem: a path string that maps to more
than one leaf. Locate now calls the existing Leaves() walk (which,
unlike a targeted descent, visits every literal key and so surfaces a
collision a single-path lookup never would) and refuses with the new
ErrAmbiguousPath if more than one leaf shares the configured path,
before ever resolving to one of them.

Verified against the pre-fix code directly: Locate("a.b") on
{"a.b":"secret","a":{"b":"decoy"}} silently returned "decoy" with no
error, and Locate("password") on a duplicate-keyed JSON document
silently returned only "eins" — exactly the silent misresolution the
audit described. Full existing test suite passes unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
scalarText truncated a scalar token's Origin at the first " #" with no
awareness of quoting, so a quoted value like password: "abc # geheim"
was truncated to "abc, leaving the untruncated remainder (# geheim")
as literal plaintext in the git blob right next to the encrypted tag,
since the full value was still what got encrypted into the tag.

Verified empirically against goccy/go-yaml (not just by reading the
code) that a quoted scalar's Origin is, in every case tested - with or
without a following real comment, with or without trailing whitespace,
at end of file or followed by more document - always exactly the
quoted literal verbatim, with no trailing trivia ever folded in. So the
fix does not need to parse or search quoted content at all: scalarText
now returns a quoted scalar's trimmed Origin as-is, and only applies
the " #" search to plain (unquoted) scalars, where it was originally
aimed and remains correct (a plain scalar's Origin can never contain
" #" as its own content in the first place - the lexer itself treats
that sequence as the start of a comment while still scanning the
scalar, whatever the author intended).

Verified directly against the pre-fix code: Locate on
password: "abc # geheim" returned span [10:14) = "abc (4 bytes) while
span.Value was already the full, correctly-parsed "abc # geheim" - the
exact partial-plaintext-leak mechanism the audit described.

Found and fixed along the way, not part of the audited P02 itself:
ast.LiteralNode (go-yaml's AST type for both | literal and > folded
block scalars) wraps a header token (just the indicator character)
separate from its actual content token, and its GetToken() - which
Locate and Leaves used unconditionally - returns only the header.
Confirmed directly against the pre-fix code that Locate on a block
scalar field returned span [10:11) = "|" with no error: a mode: value
rule naming such a field would splice a ciphertext tag over the single
header character, destroying the document instead of protecting the
secret beneath it. Locate now refuses this with a new
ErrBlockScalarUnsupported instead, and Leaves no longer offers such a
field to nebel add field's picker. There is no existing format
support to fall back on for these styles; refusing outright is the
safe behavior pending proper support.

Full existing test suite passes unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…bonus finding

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CheckoutAll's os.Remove-then-checkout had no backup step at all: it
unconditionally deletes every managed file, then restores each from
`git checkout HEAD -- <path>`, which overwrites the working tree from
HEAD regardless of what's in the index or in an unstaged edit. Both
`nebel init` (repeated) and `nebel init --version N` call this, so an
ordinary re-init could silently destroy uncommitted work with no
warning.

Added DirtyFiles, which runs `git diff --name-only HEAD -- <files>` -
this single command covers staged and unstaged changes alike, since it
compares the working tree directly against HEAD regardless of the
index. CheckoutAll now calls it before touching anything and refuses
with a new ErrDirtyManagedFiles, naming every dirty path, if any
managed file differs from HEAD.

Verified directly against the pre-fix code with a standalone probe: an
unstaged edit to a managed file was silently destroyed by CheckoutAll -
it returned decrypted=1, failures=nil, err=nil, and the file's content
reverted to what was committed, with the local edit gone. With the fix,
the same scenario (both unstaged and staged variants) is refused
outright via ErrDirtyManagedFiles, with the local edit left completely
untouched; a clean repo (the ordinary post-init case) still succeeds
normally.

go build, go vet, and go test all pass on internal/gitutil.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…(P06)

localkey.Set base64-encoded the full derived key and passed it to
gitutil.ConfigSet, which runs `git config --local <name> <value>` with
the key as a literal subprocess argument. During that subprocess's
(brief) runtime, the ready-to-use decryption key sat in its argument
list, readable by any other local user or process with sufficient
privilege via /proc/<pid>/cmdline. This is the same class of exposure
cmd/nebel/password.go's ErrPasswordArgument already refuses for the
shared password itself; the derived key wasn't getting the same
protection.

Set now writes keys to a new dedicated file (internal/localkey's
keyFileName, "nebel-keys") inside the git directory shared across
every worktree of a clone (resolved via `git rev-parse
--git-common-dir`, run directly rather than through gitutil, to keep
this fix self-contained), with 0600 permissions — owner read/write
only, tighter than .git/config's ambient permissions, which also
answers a related "harden local key storage" suggestion from the same
audit. This never puts the key on any process's argument list.

Backward compatibility: Get and All still fall back to the legacy
git-config location for any version registered there before this fix
shipped, so an existing clone keeps working without needing to re-run
`nebel init`. New registrations always go to the key file; nothing
already in git config is migrated or removed.

Several cmd/nebel e2e tests asserted directly on the old storage
mechanism (`git config --local --get filter.nebel.key...`) as their way
of checking whether a key was or wasn't registered; three of them
failed outright once Set stopped writing there, and two others (which
only checked for the key's *absence*) would have kept passing but
vacuously, no longer proving what they claimed. Added a shared
registeredKeyValue test helper that checks the actual current storage
(the key file, falling back to legacy git config, mirroring
internal/localkey.Get's own precedence) and updated all five call
sites to use it.

Verified directly against the pre-fix code: calling Set left the raw
base64 key sitting in plain `.git/config` file content under `[filter
"nebel"] key = ...` — written by exactly the `git config --local`
subprocess call whose argument list this fix removes the key from. The
audit's actual concern, the key's brief appearance in that subprocess's
own argv, isn't itself observable from within a Go test (the
subprocess exits before a test could inspect it); proving the key no
longer ends up as a plain git-config value at all is the most direct
available proxy, since getting it there is exactly what required
passing it as that argument in the first place.

Full existing test suite passes unchanged, including cmd/nebel's e2e
suite (which exercises the full init/rotate/checkout flow depending on
localkey end-to-end).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A value tag's type field (type:str, type:bool, ...) sat next to its
ciphertext as plaintext metadata, not inside anything SIV
authenticated - so changing type:str to type:bool needed no key at
all, only write access to the file the tag is stored in. Before this
fix, that silently changed how a decrypted string rendered (unquoted,
as a boolean or number, instead of a quoted string) without tripping
authentication at all. Confirmed live against the pre-fix code: a
type:str -> type:bool edit on an untouched ciphertext succeeded with
err=nil, and password: "s3cr3t" smudged back as the unquoted
password: s3cr3t.

This is a breaking ciphertext-tag format change, exactly as flagged in
the mitigation plan: every previously-committed value tag was
authenticated without its type, so simply adding type to the AAD
everywhere would fail every one of them, not just tampered ones. Fixed
via the same mechanism spec 03 AC-3.8 already uses to reject an
unsupported ALGO: value tags now come in two forms, distinguished by
that field.

- internal/siv: AAD gained a variadic extra parameter, appended as
  more length-prefixed components after the existing three - existing
  3-argument call sites are unaffected.
- internal/tag: a new AlgoAES256SIVTypeBound ("AES256_SIV_TB")
  identifies a value tag whose type is bound into the AAD. EncodeValue
  now always produces this form. Parse accepts either this or the
  legacy AlgoAES256SIV, exposing which one via a new Tag.TypeBound
  field - false for every whole-file tag (nothing to bind there) and
  for a legacy value tag, true only for the new form. A type-bound
  algo naming with no type field at all (nothing to bind) is rejected
  as malformed rather than silently accepted.
- internal/filterop: cleanValues now always encrypts with type
  included in the AAD (matching what EncodeValue just wrote);
  smudgeValues reconstructs the AAD Tag.TypeBound calls for - with
  type for a type-bound tag, without it for a legacy one - so each
  authenticates against whatever it was actually encrypted under.

No separate migration tooling exists or is needed: cleanValues already
unconditionally re-encrypts any plaintext field it finds via
EncodeValue, so a legacy value converges onto the type-bound form the
next time anything re-encrypts it - an ordinary edit, nebel rotate, or
git add --renormalize - the same way a value converges onto a new key
version today. A legacy tag's type remains unauthenticated until that
happens; this is documented as an explicit, understood limitation
(TestSmudgeValuesLegacyTagTypeStillUnauthenticated), not an oversight,
since re-authenticating it retroactively is exactly what would break
every already-committed legacy tag.

Verified directly against the pre-fix code (reverting
internal/siv/siv.go, internal/tag/tag.go, internal/filterop/filterop.go
to HEAD and re-running the new test): flipping type:str to type:bool on
a freshly-cleaned tag produced no authentication error and rendered the
value unquoted, exactly the vulnerability described above. Restored the
fix afterward; full test suite (go build, go vet, go test ./...) passes
including cmd/nebel's e2e suite.

Also updates specs/03-value-encoding.md with AC-3.10 describing the two
tag forms and the authentication guarantee.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Three follow-ups to ba8e9aa, found by a second review pass rather than
by the original audit:

1. Restores a stopgap the mitigation plan called for and Package F
   dropped: a legacy (non-type-bound) tag's type field is still
   unauthenticated, so a successful flip could previously render
   arbitrary decrypted text unquoted for TypeInt/TypeFloat/TypeBool -
   a potential structural-injection vector for a crafted plaintext, not
   just type confusion. smudgeValues now rejects a decrypted value that
   isn't a genuine literal for its claimed type (validTypeLiteral,
   using strconv's parsers) before rendering it. A type-bound tag can
   never reach this in practice - its type can't change without
   failing decryption first - so this only ever narrows what a legacy
   tag's still-possible type flip can do, without weakening the new
   protection at all.

2. Corrects an overstated claim in ba8e9aa's tag.go/specs/03 comments:
   "a value converges onto the type-bound form the next time anything
   re-encrypts it" is true, but only protects the *current* committed
   tag going forward. An attacker with write access can still
   resurrect an old legacy ciphertext from git history (or a stale
   branch or clone) with its type re-flipped, and it authenticates
   exactly as it did when first committed, since spec 11 AC-11.5 keeps
   every key version registered indefinitely - upgrading the current
   commit doesn't revoke it. Fully closing that needs actively revoking
   the old key version, which rotation does not do; out of scope here.
   Documented plainly rather than left implied.

3. Fixes a real interaction between Package D and Package F, confirmed
   with a new e2e test rather than left as a hypothesis: DirtyFiles
   (gitutil, P01's dirty-check) compares by running the clean filter,
   which is unavoidable for a filtered path - `git diff HEAD` has to
   clean the working tree to compare it against HEAD's (also filtered)
   blob. A legacy value tag re-encrypts to different bytes the moment
   anything cleans it again, since the AAD it's produced under changed
   - so a repeated `nebel init` on a repo with an existing legacy tag,
   and no actual local edits, now refuses with ErrDirtyManagedFiles.
   The new e2e test (cmd/nebel/legacytag_e2e_test.go) reproduces this
   against a real repo with a real derived key and confirms the fix:
   `git add --renormalize` alone is not enough, since it only updates
   the index, and DirtyFiles compares against HEAD regardless - the
   renormalized tag must also be committed. ErrDirtyManagedFiles' error
   text and doc comment now say so explicitly.

Full test suite (go build, go vet, go test ./...) passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@MuffinUser
MuffinUser merged commit 2e7b099 into main Sep 29, 2026
6 checks passed
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.

1 participant