Skip to content

fix(identity): derive the enrollment ID of a boolpolicy identity from its members - #2146

Merged
AkramBitar merged 4 commits into
LFDT-Panurus:mainfrom
Built-by-Sign:fix/boolpolicy-enrollment-id
Aug 11, 2026
Merged

fix(identity): derive the enrollment ID of a boolpolicy identity from its members#2146
AkramBitar merged 4 commits into
LFDT-Panurus:mainfrom
Built-by-Sign:fix/boolpolicy-enrollment-id

Conversation

@EvanYan1024

@EvanYan1024 EvanYan1024 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Fixes #2145

What

Derive the enrollment ID of a boolpolicy identity from its member identities, mirroring the recursive htlc.NewAuditDeserializer pattern:

  • boolpolicy.NewAuditInfoDeserializer now takes the parent multiplex deserializer and resolves each component's audit info through it; the policy identity reports the enrollment ID shared by all components. The fabtoken and zkatdlog drivers pass the multiplexer at registration.
  • Components with no enrollment ID of their own (e.g. a nested composite spanning enrollments) or disagreeing components yield the legacy empty value — composites resolve recursively, so a member may itself be a policy or multisig identity.
  • A component whose audit info is missing is legal: GetAuditInfo returns nil for an identity that is not registered locally and WrapAuditInfo preserves the empty entry, so such a component contributes no enrollment ID rather than failing the audit path. This exemption is applied before subtype resolution, so a component of an unknown identity type carrying no audit info also yields no enrollment ID rather than an error.
  • Malformed audit info — a non-empty component audit info that cannot be resolved, or a component count mismatch — is an error instead of a silent empty value, and every component is resolved before the result is declared so corruption in a later component is never masked by an earlier "no common EID" outcome.
  • The wire-supplied component identities are validated at this deserialization boundary, as GetAuditInfoMatcher and DeserializeVerifier already do. Nothing else on the audit path reaches that validation: Request.IsValid only checks action structure and metadata consistency, and owner verifiers are deserialized solely by the driver validator at commit.
  • A component whose inner deserializer returns neither audit info nor an error contributes no enrollment ID instead of panicking.

Why

AuditInfo.EnrollmentID() returned "" for every policy identity, and the two sides of a transaction are affected differently.

The input side carries the composite identity itself — both drivers put a single sender in TransferInputMetadata — so a policy-owned input reached the auditor with no enrollment attribution at all, and completeInputsWithEmptyEID then reassigned it to whichever enrollment ID the output stream reports first. That is the gap this PR closes.

The output side is separate: output rows are expanded per component and already resolve each member's own enrollment ID, so they are attributed correctly but counted once per member. That duplicate amount accounting is fixed by #2148. Only the two together bring a policy wallet's audited movements back to the true net; see #2145 for a worked example.

In our test environments the auditor's audit balances for policy wallets were corrupted by this pair of defects.

Testing

  • Unit tests cover: common-enrollment derivation, single member, legal cross-enrollment components, nested policies (both the common-EID and cross-enrollment inner case), empty member enrollment IDs, missing component audit info (alone, alongside a resolvable component, followed by a malformed component, and on an unknown identity type), invalid component identities (duplicate and empty), a nil audit info returned by the inner deserializer, the malformed cases (unresolvable component, count mismatch including an empty audit-info blob against a non-empty policy, a policy identity with no components, garbage audit info), the masked-later-corruption case, and the zero-value deserializer.
  • FuzzDeserializeAuditInfoNoPanic fuzzes the recursive deserialization path (seeds: valid single/two-member policies in both identity encodings, empty, truncated, empty audit-info blob, count mismatch, unknown member type, 5-deep nesting; inputs capped at 64 KiB like the neighbouring identity targets), wired into .github/workflows/nightly-fuzz.yml; 20s local run clean at ~50k execs/sec.

@EvanYan1024
EvanYan1024 force-pushed the fix/boolpolicy-enrollment-id branch 3 times, most recently from a52cc93 to 5db7643 Compare August 5, 2026 03:39
@AkramBitar AkramBitar added the enhancement New feature or request label Aug 5, 2026
@AkramBitar AkramBitar added this to the Q3/26 milestone Aug 5, 2026
@AkramBitar

Copy link
Copy Markdown
Contributor

@EvanYan1024

Thanks a lot for finding and solving this issues. Great work. Thanks a million.

Policy identities (boolpolicy) always returned an empty enrollment ID, so policy-owned inputs and outputs lost their ownership attribution and eid-based accounting either dropped or misbooked those amounts.

The fix resolves each component's audit info recursively through the multiplexer and returns the enrollment ID that all components share. If a component has none, or they disagree, the result is "". If a component can't be resolved or its audit info is malformed, it's an error.

My concern is the missing case, not the malformed one.

An empty component audit info is legal today: WrapAuditInfo accepts empty entries, and SignatureService.GetAuditInfo returns nil for an identity that isn't registered locally. With this change that empty entry fails resolution, and token/request.go turns the failure into a rejected audit record — so a transaction that used to audit fine with eid="" now fails.

@adecaro
Could you please let us know what your opinion in this one?

Thanks a lot,
Akram

@EvanYan1024

Copy link
Copy Markdown
Contributor Author

Thanks Akram, this is a valid compatibility concern. You are right that the current implementation conflates two different cases: a malformed outer audit-info with the wrong number of component entries, and a correctly shaped policy audit-info containing an empty component entry.

The latter is legal in the existing flow: GetAuditInfo may return nil for an identity that is not registered locally, and WrapAuditInfo preserves that empty entry. Before this PR, such a policy resolved to an empty enrollment ID without failing the audit path.

I will preserve that behavior by treating an empty component audit-info as “no enrollment ID available”, while continuing to resolve all remaining non-empty components so that a later malformed entry is not masked. Count mismatches and non-empty but unresolvable audit infos will remain errors. I’ll add regression tests for the empty-entry case and for an empty entry followed by malformed data.

@AkramBitar AkramBitar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@EvanYan1024

Thanks for the fixes that you added the empty-component case reads correctly now, and the three new regression tests cover it.

I left three comments while reading the rest. The first is the only one I'd like to discuss with you to see if we need to resolve it before merging; the other two are minor.

Regards,
Akram

Comment thread token/services/identity/boolpolicy/deserializer.go
Comment thread token/services/identity/boolpolicy/deserializer.go
Comment thread token/services/identity/boolpolicy/deserializer.go
… its members

A policy identity's AuditInfo always reported an empty enrollment ID, so
policy-owned inputs and outputs reach the auditor with no enrollment
attribution and downstream eid-keyed accounting misbooks them.

Resolve each component's audit info through the parent multiplex
deserializer (same recursive pattern as htlc.NewAuditDeserializer) and
report the enrollment ID shared by all components. A component with no
enrollment ID of its own (e.g. a nested composite spanning enrollments)
or disagreeing components yield the legacy empty value. Malformed audit
info -- an unresolvable component or a component count mismatch -- is an
error rather than a silent empty value, and every component is resolved
before the result is declared so later corruption is never masked.

Add a fuzz target for the new recursive deserialization path and wire it
into the nightly fuzz workflow.

Signed-off-by: Evan <evanyan@sign.global>
… EID

An empty component audit info is legal: WrapAuditInfo accepts empty
entries and audit info lookup returns nil for identities not registered
locally. Resolve such components to "no enrollment ID" instead of
failing, so transactions that audited fine with an empty enrollment ID
keep doing so. Errors remain for malformed data: a non-empty component
that cannot be resolved or a component count mismatch, and a missing
component does not mask corruption in a later one.

Signed-off-by: Evan <evanyan@sign.global>
…info

commonEnrollmentID accepted wire-supplied component identities without the
validation both siblings in the file apply, so a policy carrying a duplicate
or empty component still yielded a derived enrollment ID. The audit path
never runs the verifier that would reject it: Audit reaches AuditRecord with
verifyActions false, and auditor.Service.Validate, the only entry point that
calls IsValid, has no caller. Such an identity is therefore attributed in
movements and transaction records and only rejected later at commit.

Also skip a component whose inner deserializer reports neither audit info
nor error instead of dereferencing it. That is unreachable through the real
multiplexer, but NewAuditInfoDeserializer takes an exported interface, so an
implementation returning (nil, nil) turns a caller-visible error into a
panic.

Signed-off-by: Evan <evanyan@sign.global>
…ype resolution

The boolpolicy enrollment-ID note said "an unresolvable component" is an
error without saying which rule wins when a component is both of an
unknown identity type and carries no audit info. The exemption is applied
before the member's type is resolved, so that case yields an empty
enrollment ID. Say so, and pin the contract with a test alongside the
existing unknown-type-with-audit-info error case.

Signed-off-by: Evan <evanyan@sign.global>
@AkramBitar
AkramBitar force-pushed the fix/boolpolicy-enrollment-id branch from 4988ad0 to 77c3dba Compare August 11, 2026 05:13
@AkramBitar

Copy link
Copy Markdown
Contributor

@EvanYan1024.

Thanks a lot for the great work you did on this PR. I really appreciate it!

Best reagards,
Akram

Summary

This PR fixes an issue where boolean-policy identities were missing their enrollment ID during auditing, which could lead to incorrect audit records. It derives the EID from the policy's components and adds several hardening changes, tests, and fuzz coverage.

Problem

boolpolicy.AuditInfo.EnrollmentID() always returned "". As a result, any token owned by a boolean-policy (composite) identity had no enrollment ID, so the auditor couldn't attribute those inputs to an owner. completeInputsWithEmptyEID then fell back to blindly assigning the amount to the first output's EID, resulting in incorrect audit records.

Fix

Derive the EID from the policy's components: deserialize each component's audit info, resolve its enrollment ID, and return it if all components agree on the same EID. Otherwise, fall back to "".

The fix also includes some additional hardening:

  • Validate component identities (duplicates/empty identities) before resolving them, consistent with the existing sibling methods.
  • Add a nil guard for missing member audit info.
  • Update both drivers to use NewAuditInfoDeserializer(d) instead of the zero-value deserializer.
  • Add new unit tests and a fuzz target, wired into nightly-fuzz.yml.

Out of scope

The output-side double-counting issue in amount sums is deliberately out of scope and is tracked separately in issue #2148.

@AkramBitar
AkramBitar merged commit d836273 into LFDT-Panurus:main Aug 11, 2026
152 checks passed
@EvanYan1024
EvanYan1024 deleted the fix/boolpolicy-enrollment-id branch August 11, 2026 08:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

boolpolicy: policy identity AuditInfo always reports an empty enrollment ID

2 participants