Siyao Meng created HDDS-16955:
---------------------------------

             Summary: SCM secret key rotation proposed after a Ratis submit 
timeout drops the key of the timed out rotation on all SCMs
                 Key: HDDS-16955
                 URL: https://issues.apache.org/jira/browse/HDDS-16955
             Project: Apache Ozone
          Issue Type: Bug
            Reporter: Siyao Meng
         Attachments: 
MC-1-propose-pending-secret-key-again-after-failed-update.patch, 
TestBugMC1SubmitTimeoutDropsKey.java

h3. Mechanism
{{SecretKeyManager.checkAndRotate}} proposes the whole key list: the keys of 
the applied state plus one newly generated key. {{checkAndInitialize}} proposes 
the keys of the local key file, or one newly generated key if the file has no 
valid key. {{SecretKeyStateImpl.updateKeys}} replaces the list when the request 
is applied. {{SCMRatisServerImpl.submitRequest}} waits 
{{ozone.scm.ha.ratis.request.timeout}} (default 30s) for the reply and then 
gives up without cancelling the request, so the request stays in the Raft log 
and is applied later if the leader keeps its term. The callers treat the 
resulting {{SCMException(TIMEOUT)}} as a failure: 
{{SecretKeyManagerService.run}} logs it and tries again at the next check, 
{{SCMSecurityProtocolServer.checkAndRotate}} returns it to the caller. The next 
attempt still reads the old applied state, generates another key and proposes a 
list without the first new key. Both requests are then applied in log order: 
the first new key is the current key for a moment and is then removed from 
every SCM and from its key file, although it has not expired.

HDDS-16142 fixed the same assumption (a submit that timed out did not happen) 
for the deleted block transaction summary.

h3. Trigger
# The SCM leader keeps its term but cannot commit or apply a request within the 
request timeout.
# A rotation is proposed and its submit times out. The request stays in the 
Raft log.
# A second rotation is proposed before the first one is applied. Either {{ozone 
admin scm rotate --force}} is repeated after the timeout error, or the rotation 
checker reaches its next check. The check interval 
{{hdds.secret.key.rotate.check.duration}} is 10m by default, so on the checker 
path the stall has to last that long.
# The stall ends and both requests are applied.

A stall in which the leader loses its term does not trigger this. With the 
default 5s leader election timeout a plain follower outage ends in 
{{NotLeaderException}} first, and a new leader accepts requests only after it 
has applied every earlier entry.

Run with default timeouts: every follower the quorum depends on (both followers 
of a three SCM group, or the only live one) keeps answering heartbeats but does 
not acknowledge log entries (see Reproduction). The leader kept its term and 
the forced rotation timed out after 30s. With only one of two followers 
affected nothing stalls. Argued, not run: this is how a follower whose Raft log 
write hangs looks to the leader (in Ratis 3.3.1 the reply to a heartbeat does 
not wait for the log write), and a leader whose {{StateMachineUpdater}} thread 
is blocked for 30s gives the same timeout.

The first key of a cluster has the same problem ({{checkAndInitialize}} with no 
saved key). That case is covered by a unit test only.

h3. Impact
* A token signed with the removed key cannot be verified. SCM answers 
{{getSecretKey}} with null and the verifier throws "Can't find the signing 
secret key". Nothing restores the key. No invalid token is accepted.
* The key is the current key only between the two applies, 28 ms in the 
reproduction run (longer if other requests sit between the two in the log). 
Signers in that window: SCM itself signs container tokens with the applied 
current key ({{ContainerTokenSecretManager}}, with 
{{hdds.container.token.enabled}}), for example for close container commands, 
which are sent again later. OM and datanodes fetch the current key at start and 
then once per check interval after their key has passed the rotation period 
({{DefaultSecretKeySignerClient}}). If such a fetch falls into the window, that 
process signs with the removed key until its key passes the rotation period 
(default 1d), a restart or {{ozone admin om fetch-key}}.
* No real signer was seen to pick up the removed key. In the reproduction the 
rejected token is signed by the test with the key taken from the Raft log.

h3. Reproduction
PASS on unmodified source with default Ratis timeouts. 
[^TestBugMC1SubmitTimeoutDropsKey.java] ({{hadoop-ozone/integration-test}}) 
starts a secure mini cluster with three SCMs. The Ratis test hook on 
{{RaftServerImpl.appendEntries}} makes both followers hold every call that 
carries entries while heartbeats pass. A forced rotation over the SCM secret 
key protocol fails with {{TimeoutException}} after 30s, with the leader still 
leader in the same term and the key list unchanged. The call is repeated and 
the followers are released once the second request is in the leader's log. The 
test then reads both {{updateKeys}} requests from the Raft log and shows that 
the new key of the first one is missing on all three SCMs and from 
{{getSecretKey}} while unexpired, and that the datanode block token verifier 
rejects a token signed with it and accepts one signed with the surviving key. 
About 70s including cluster start. A passing test means the defect is present.

The rotation checker calls the same method without the force flag. That path is 
covered by the unit test in the patch, not run on a cluster.

h3. Suggested fix
The patch changes only the proposing side. The alternative is to change what 
{{updateKeys}} does on apply (merge into the current list, or carry the key the 
proposal was based on and reject a stale one). That changes how a replicated 
request is applied, so SCMs of two versions applying the same log would end 
with different key lists unless it is gated by a layout feature. It is not 
needed for this defect.

h3. Patch
[^MC-1-propose-pending-secret-key-again-after-failed-update.patch], against 
025266b52a6dcaf39508607cad736799c40907e8. It also applies to master at 
a65a32e7c4b.

{{SecretKeyManager}} remembers the new key of an update that failed. While that 
key is not in the applied key list, the next rotation proposes it again, on top 
of the then applied state, instead of generating another key. If the first 
request is applied after the second one was proposed, the second one carries 
the same key and removes nothing. If the first request was lost, the second one 
does the rotation. Once the first request has been applied, its key is in the 
applied list and the next rotation generates a new key, as it does today. This 
is decided by key id, not by creation time: an applied key has been through the 
Raft log, which stores its times in milliseconds, while the proposer's copy has 
the precision of the JVM clock. A rotation proposal never contains a key id 
twice, also when the first request is applied while the next proposal is being 
built ({{SecretKeyStateImpl.updateKeys}} cannot apply such a list). At 
initialization ({{checkAndInitialize}} with no saved key) the remembered key is 
proposed again unless it has expired: the applied list is empty there, so there 
is nothing to compare it with, and proposing the same single key again is 
harmless if the first request is applied in the meantime. The remembered key is 
forgotten when an update succeeds. It is replaced by a new key when it has 
expired, or when it would no longer become the current key because a newer key 
has been applied. This also holds across a leader change: only this SCM can 
have an unapplied proposal of its own in the log, because Ratis accepts 
requests only from a leader that has applied all earlier entries.

Nothing changes as long as no update fails. After a failed update that has not 
been applied, the next rotation on that SCM uses the key that was generated at 
the failed attempt, so a forced rotation that timed out and is repeated before 
the first request is applied rotates once, not twice. The log then says that 
the key of the last failed update is proposed again. After a failed update that 
has been applied, the next rotation behaves as on unmodified code.

Tests are in the existing {{TestSecretKeyManager}}. Proposals are applied as a 
state machine gets them, after {{toProtobuf}} and {{fromProtobuf}}. 
{{testRotateAgainAfterTimeout}} (scheduled and forced) and 
{{testInitializeAgainAfterTimeout}} fail without the change because the key of 
the first update is missing from the final list. 
{{testRotateAfterTimedOutUpdateIsApplied}} (timed out, applied after all, 
rotated again), {{testRotateWhileTimedOutUpdateIsApplied}} (applied between the 
two reads of the applied state), {{testRotateAfterTimeoutAndNewerKeyApplied}} 
and {{testExpiredKeyIsNotProposedAgain}} pass with and without the change. They 
guard the cases in which the key must not be proposed again.

With the change the secret key unit suites of {{hdds-server-framework}} 
({{TestSecretKeyManager}}, {{TestLocalKeyStore}}, {{TestManagedSecretKey}}, 24 
tests) and the mini cluster suites {{TestSecretKeysApi}}, 
{{TestSecretKeySnapshot}} and {{TestBlockTokensCLI}} (8 tests) pass, checkstyle 
is clean. Also run with the change on a three SCM mini cluster: a forced 
rotation times out, the stall ends and the request is applied, then the 
rotation is forced again. That rotation succeeded with a new key, and all three 
SCMs stayed up with the same three keys. With the change the reproduction fails 
at the assertion that the key is gone: the forced rotation still times out, the 
repeated call proposes the same key, and the key list ends with that key and 
the old one.

Found by TLA+ model checking and code review of the SCM secret key rotation and 
distribution paths under HDDS-15926, on commit 
025266b52a6dcaf39508607cad736799c40907e8. Checked against HDDS issues and 
apache/ozone pull requests for duplicates before filing. The attached patch is 
a proposal for review. Generated with Specula (Claude Opus 5.5).



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to