Siyao Meng created HDDS-16956:
---------------------------------
Summary: SCM secret key file is rewritten in place without sync: a
crash can leave it empty or cut, and a new leader then drops the keys or cannot
initialize
Key: HDDS-16956
URL: https://issues.apache.org/jira/browse/HDDS-16956
Project: Apache Ozone
Issue Type: Bug
Reporter: Siyao Meng
Attachments: MC-2-write-secret-key-file-atomically-and-durably.patch,
TestBugMC2SecretKeyFileNotCrashSafe.java
h3. Mechanism
{{LocalSecretKeyStore.save}} truncates the secret key file and rewrites it in
place, with one write per key and no sync. SCM keeps its symmetric secret keys
only in memory and in this file, while the applied index is persisted
separately in scm.db. A crash can therefore leave the file empty or cut while
scm.db already records an index at or past the key update.
After a restart Ratis replays only the entries above the index in scm.db.
{{LocalSecretKeyStore.load}} has a single caller,
{{SecretKeyManager.checkAndInitialize}}, which only runs on a leader. So the
restarted SCM is caught up but has no keys in memory until the next key update
is applied to it ({{hdds.secret.key.rotate.duration}}, default 1d). If it
becomes leader before that, {{checkAndInitialize}} trusts the file:
* *Empty file.* {{load}} returns an empty list, as for a missing file. The
leader generates one new key and replicates it through
{{SecretKeyState.updateKeys}} as the complete key set. Every SCM replaces its
keys with it and rewrites its own file, so the committed, unexpired keys are
gone from the whole cluster.
* *Cut file.* {{load}} throws. Every initialization attempt fails,
{{SecretKeyManagerService}} logs "Error occurred when updating SecretKeys." at
each rotation check, and the leader has no current key for as long as it leads.
h3. Trigger
The key file must end up empty or cut on an SCM whose scm.db is at or past the
key update, and that SCM must become leader (election or {{ozone admin scm
transfer}}) before the next key update reaches it. Two ways to get such a file:
# *Power loss or kernel crash after the unsynced save.* Shown with a device
image probe (see Reproduction), and only for the first save of the key file on
XFS: the image held the index and a 0 byte key file at some point within about
a minute after the save. It was not seen for later saves (rotations) or on
ext4, and no image held a cut file. By the code, the first save on an SCM is
its first applied key update or its first snapshot install (new cluster, newly
added SCM).
# *Process kill or write error inside the save, on a path that is not
replayed.* A SIGKILL during {{save}} leaves empty and cut files (attached
test). On the apply path this heals, because the entry is applied again after
the restart. It does not heal in a snapshot install, where
{{SCMStateMachine.reinitialize}} replaces scm.db before it saves the keys
(HDDS-16923 describes that window with a missing or stale file). The kill
inside a snapshot install was not run here.
h3. Impact
Run on a secure mini cluster with 3 SCMs, 1 OM and 1 datanode, default rotation
and expiry:
* *Empty file.* After the leadership transfer all three SCMs hold a single new
key and {{SecretKeyProtocol.getSecretKey}} returns null for the committed key,
which would not expire for 9 more days. OM still signs with it, and the
datanode side check ({{BlockTokenVerifier}}) rejects a block token that OM
issued after the event: "Can't find the signing secret key". How long that
lasts was not measured (same code paths as in HDDS-16923).
* *Cut file.* The new leader logs one ERROR per rotation check and
{{SCMSecurityProtocolServer.getCurrentSecretKey}} throws "Secret key
initialization is not finished yet." (called on the server object, not over
RPC). The other SCMs still hold the key in memory, and after leadership was
transferred to one of them the secret key RPC returned it again.
Both fail closed: no token is accepted that should be rejected.
h3. Reproduction
[^TestBugMC2SecretKeyFileNotCrashSafe.java] (path and Maven command in the
class comment) passes on unmodified source, which means the defect is present.
Its first test kills a child JVM that runs {{save}} in a loop with 10 keys: of
40 kills, 8 left an empty file and 4 a cut one (this shows that both states
exist, not how often they occur). The two cluster tests replace the key file of
a stopped follower by an empty or a cut one, restart it, assert that it catches
up with no keys in memory (this part is not injected), transfer leadership to
it and check the outcomes above.
The power loss route was checked with a device image probe (not attached) in a
Linux VM: kernel 7.0.14, XFS and ext4 on a loop device, default mount options,
{{dirty_writeback_centisecs}} 1500 (the kernel default is 500, so the window is
longer here). It runs {{save}}, then an unsynced RocksDB batch that stands for
the applied index, and copies the device image while the writer is alive. On
XFS, for the first save, the image held the index and a 0 byte key file from
right after a WAL sync (as in a DB checkpoint) to the last image at 46 seconds,
and without a sync (WAL dirty for 25 seconds before the save) at about 51 and
65 seconds. Each scenario ran once and no image was fed to a cluster.
h3. Patch
[^MC-2-write-secret-key-file-atomically-and-durably.patch], against
025266b52a6dcaf39508607cad736799c40907e8. It also applies to master at
a65a32e7c4b.
{{LocalSecretKeyStore.save}} now writes the keys to a new temporary file next
to the key file, syncs it, renames it over the key file and syncs the
directory, using the Ratis {{AtomicFileOutputStream}} as {{YamlUtils.dump}}
does. A failed write aborts the stream, so an incomplete file is never renamed.
The temporary file is created owner only, as in {{KeyStorage.storeKey}}, and a
leftover one is deleted, not reused. The content is byte for byte what the old
code wrote, so the format is unchanged for existing key files and for a
downgrade. The file is replaced on each save: a symlink at the key path would
become a regular file, and hard links, ACLs or a non default owner are not
carried over.
The cost is two syncs per key update on the Ratis apply thread, inside the key
state write lock (once per scheduled or forced rotation and per snapshot
install). A save that fails, now including a failed sync of the file, stops the
SCM as a failed write did before, and leaves the previous file in place.
{{TestLocalKeyStore}} gets three cases that fail without the change: the file
is replaced and not rewritten in place, a failed write keeps the previous keys,
and a leftover temporary file is not reused. With it the symmetric key unit
suites, {{TestSecretKeySnapshot}} and {{TestSecretKeysApi}} pass and checkstyle
is clean. On the test machine {{TestSecretKeysApi}} often failed in cluster
startup with the race in HDDS-12174, with and without the patch. With the patch
no kill in the reproduction leaves a damaged file.
The patch only stops new damage. What a leader does with a missing, empty or
unreadable key file is unchanged (the two cluster tests still pass with it): a
file that is already empty or cut from an earlier crash is not repaired, a file
lost or damaged by other means still leads to the outcomes above, and the
snapshot install ordering of HDDS-16923 still leaves a stale or missing file,
with the first outcome above. Whether a leader without keys should ask its
peers, as a snapshot install does with {{getSecretKeysFromLeader}}, or load its
file at startup so that a problem shows before it leads, is a larger change and
is left open here. This patch and the one attached to HDDS-16923 touch
different files and apply together.
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. Related but not a
duplicate: HDDS-16923 (open) covers the snapshot install ordering and names the
in place, unsynced save as the gap its patch leaves open. 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]