Security review remediation (S1)¶
Re-review packet for the first security review of KSecureMessage (after milestones 1–25, R1 and R2). The review reported nine findings, F1–F9. S1 fixes their trust roots. Where the vulnerable behavior was part of a format or an API, S1 versions or breaks it on purpose: security takes precedence over compatibility with the vulnerable behavior.
S1 re-review result: passed with residual risks (F1/F2, F3, F4, F5/F6, F8, F9 closed; F7 partially fixed), plus new findings N1–N3 and documentation findings D1–D7. S1.1 (section S1.1 follow-up) addresses exactly those. S1.1 re-review result: passed with residual risks, plus findings N4 and N5; S1.2 (section S1.2 follow-up) addresses those. S1.2 re-review result: passed with residual risks (N5 closed), plus two LOW findings N6 and N7 and the cleanup side effects INFO-3; S1.3 (section S1.3 follow-up) addresses those. S1.3 final re-review result: passed with residual risks (N6 and N7 closed, N4 closed with them, F7 accurately documented as a residual), plus two new LOW findings N8 and N9 in the INFO-3 cleanup behavior, which do not restore attacker authority; ready for a public v0.x release with documented residual risks (section S1.3 final re-review).
This page keeps three things apart:
- Implementation status: what the code and documentation do (FIXED, PARTIALLY FIXED).
- Independent review result: only an independent re-review closes a finding; rows marked CLOSED name the re-review that closed them.
- Accepted residual risk: a finding that is documented and accepted for v0.x instead of fixed (F7, N8, N9); it is not closed.
Final status: F1/F2, F3, F4, F5/F6, F8, F9 CLOSED (S1 re-review); N1, N2, N3 CLOSED (S1.1 re-review); N5 CLOSED (S1.2 re-review); N4, N6, N7 CLOSED (S1.3 final re-review); F7 PARTIALLY FIXED — RESIDUAL RISK DOCUMENTED; N8, N9 LOW — ACCEPTED — RESIDUAL RISK DOCUMENTED; D1–D7 FIXED — REVIEWED, NOT INDIVIDUALLY CLOSED (the re-reviews checked the corrected documentation but gave no per-finding verdict for them). None of S1, S1.1, S1.2 or S1.3, nor their re-reviews, is a comprehensive audit: the re-reviews were targeted at these findings.
Path prefixes: P = core/protocol/src/commonMain/kotlin/dev/kreienbuehl/ksecuremessage/protocol,
C = client/core/src/commonMain/kotlin/dev/kreienbuehl/ksecuremessage/client,
S = server/core/src/main/kotlin/dev/kreienbuehl/ksecuremessage/server,
K = server/ktor/src/main/kotlin/dev/kreienbuehl/ksecuremessage/server/ktor,
Q = storage/client/sqldelight/src/commonMain,
V = storage/server/sqldelight/src/main,
E = storage/encryption/src/commonMain/kotlin/dev/kreienbuehl/ksecuremessage/storage/encryption.
Summary¶
| ID | Severity | Root cause | Fix | Status |
|---|---|---|---|---|
| F1/F2 | HIGH | Registration was trust on first registration: anyone could register a device under an existing UserId and then act as one of the user's devices |
Host-provided DeviceRegistrationAuthorizer, required for every first registration, including a user's first device |
CLOSED (re-review) |
| F3 | MEDIUM | SessionInitiationId v1 hashed ephemeral-key bytes (Ed25519 half) and prekey IDs that X3DH and the v1 associated data do not authenticate |
Session initiation v2: ID = SHA-256 of a canonical transcript that is also the session's associated data | CLOSED (re-review) |
| F4 | MEDIUM | The sender DeviceAddress of a first contact came from the unauthenticated envelope; submission was public |
Signed message submission (server checks sender = signer) and addresses in the v2 transcript | CLOSED (re-review) |
| F5/F6 | LOW | Request-supplied identifiers (possibly with CR/LF) were interpolated into log lines | Log-only escaping (logSafe, forLog) of every request-supplied identifier |
CLOSED (re-review) |
| F7 | LOW | A plaintext storage_encryption.format = 0 marker sent an encrypted database back through the plaintext migration |
Structural checks, StorageKeyProvider.hasKeys() and an authenticated migration intent (record type 13); S1.1: residual risks documented (intent replay, provider state trust, Android hasKeys) |
PARTIALLY FIXED — RESIDUAL RISK DOCUMENTED |
| F8 | LOW | Freshness check and nonce prune used different callers' clocks; a nonce could be pruned while its request was still fresh | Monotonic prune watermark inside the atomic claim | CLOSED (re-review) |
| F9 | LOW | A PreKeyMessage on a locally initiated legacy session was checked against the initiator slot, which is the local key, and then pinned |
Responder-role check in the engine; legacy pins only from sessionRemoteIdentityKey |
CLOSED (re-review) |
| N1 | MEDIUM | The registration authorizer received no host authentication context, so "may this caller register for this UserId?" was not expressible |
Generic DeviceRegistrationAuthorizer<C> with the host's context; required DeviceRegistrationContextExtractor<C> in the Ktor routes; no context = denied |
CLOSED (S1.1 re-review) |
| N2 | LOW | userState ("user has no devices") was read outside the atomic registration write and presented as a fact |
DeviceRegistrationUserState and hasRegisteredDevices removed; no authorization input depends on KSecureMessage's registrations |
CLOSED (S1.1 re-review) |
| N3 | LOW | An unanswered v1 initiation could win the collision comparison against a v2 initiation that the upgraded peer needs, deadlocking both sides | Unanswered v1 initiations never win, are never sent again and are replaced by v2 on receive and on send | CLOSED (S1.1 re-review) |
| N4 | LOW | Host code throwing an Error (AssertionError, NotImplementedError) bypassed the Exception-only wrapper at the authorizer and extractor boundaries and reached Ktor's logging with message and stack |
One boundary policy: every host Throwable wrapped and logged by class name only; the propagated exceptions are refined by N7 (S1.3) |
CLOSED (S1.3 final re-review; partial after S1.2, completed by N7) |
| N5 | MEDIUM | The pre-S1 cleanup removed a malicious device but left the offline recovery key it may have planted ACTIVE, so the attacker kept same-user authority | Offline, transactional, tested cleanup that also revokes the user's recovery key state and deletes its reset and challenges; fresh key from a legitimate device | CLOSED (re-review) |
| N6 | LOW | The cleanup could be pasted into an interactive sqlite3 prompt, which continues after a failure and then commits a partial cleanup; a mistyped device matched nothing and still revoked the recovery key while the attacker's device stayed registered |
One script file run only as sqlite3 -bail db < script; in-transaction guards before the first change (listed devices exist, none duplicated, list not empty, schema 8, exhausted epochs only where listed); mandatory post-cleanup verification |
CLOSED (S1.3 final re-review) |
| N7 | LOW | The host boundaries rethrew every CancellationException and every VirtualMachineError, so host code could leak its message (on CIO into the 500 body and the log) with a synthetic cancellation or InternalError |
Only genuine cancellation (coroutine no longer active), OutOfMemoryError and StackOverflowError propagate; everything else is sanitized, identically at both boundaries |
CLOSED (S1.3 final re-review) |
| D1–D7 | DOC | Documentation described unreachable authorizer inputs, a racy value as authoritative, and understated legacy and storage residuals | Corrected, see S1.1 follow-up | FIXED — REVIEWED, NOT INDIVIDUALLY CLOSED |
| N8 | LOW | The pre-S1 cleanup keeps mailbox rows sent as a cleaned address (INFO-3), so an envelope a suspect device submitted before the cleanup may still be delivered afterwards | None in v0.1.0: accepted availability/security tradeoff, see N8 | ACCEPTED — RESIDUAL RISK DOCUMENTED |
| N9 | LOW | Kept consumed one-time prekey tombstones (INFO-3) can hold IDs a pre-S1 attacker published and drained; a returning legitimate device's uploads of those IDs are skipped silently, so the server may hold no one-time prekeys for it | None in v0.1.0; likely future direction documented, see N9 | ACCEPTED — RESIDUAL RISK DOCUMENTED |
F1/F2 — Unauthenticated user membership at registration¶
Original severity: HIGH.
Root cause. SecureMessageServer.registerDevice accepted any
DeviceAddress whose key signed the request. Device recovery (M14), offline
recovery key registration (M18), rotation/revocation (M19) and reset
(M23) treat "a registered device of the same UserId" as an authority. A
stranger who registered alice/evil could authorize a recovery of Alice's
phone, provision Alice's offline recovery key if none existed, and manage
its reset. E2EE plaintext stayed encrypted.
Fix.
DeviceRegistrationAuthorizer(S/DeviceRegistrationAuthorizer.kt), required constructor parameter ofSecureMessageServer(storage, clock, deviceRegistrationAuthorizer, recoveryKeyResetPolicy = null); no default, no allow-all class in production code.registerDeviceorder: size → proof of possession (ServerAuth with the key in the body, window, nonce) → existing registration (same key →204without asking; other key →409) → host authorization (S1: withUSER_HAS_NO_…/USER_HAS_REGISTERED_DEVICES; S1.1: with the host context instead) → atomicregister. Nothing is stored beforeAuthorized.DeviceRegistrationException.NotAuthorized→403 registration_not_authorized; an authorizer exception →500 internal_errorwithout its text.DeviceRegistrationRepository.hasRegisteredDevices(userId)(in-memory and SQLDelight).- Registration's public key is now decoded as canonical Base64 like every other field.
- Client:
SecureMessageTransportException.DeviceRegistrationNotAuthorized. - Recovery flows are unchanged: their authority is now exactly the host-authorized membership (one trust root, no scattered checks).
Changed files: S/DeviceRegistrationAuthorizer.kt (new),
S/SecureMessageServer.kt, storage/core/.../ServerStorage.kt,
storage/server/inmemory/.../InMemoryServerStorage.kt,
V/kotlin/.../SqlDelightServerStorage.kt, V/sqldelight/.../ServerState.sq,
K/KSecureMessageRoutes.kt, K/HttpDtos.kt, C/SecureMessageTransportException.kt,
client/ktor/.../KtorSecureMessageTransport.kt, samples/jvm-e2e/app/.../Main.kt.
Invariant. A DeviceAddress becomes registered only if the request
proved possession of the key and the host authorizer returned
Authorized for that address and key. The authorizer never replaces a key.
Adversarial tests.
server:core DeviceRegistrationAuthorizationTest:
f1FakeDeviceCannotJoinAnExistingUser (after denial: every
ProtectedEndpoint is DeviceNotRegistered, M14 authorization is
AuthorizerNotRegistered, M18 provisioning impossible, drain/publish of
Alice's address fail; registration, recovery key and reset state,
prekeys and mailbox unchanged), f1FakeFirstDeviceOfAUserIsDenied,
f2AuthorizedSecondDeviceParticipatesInSameUserFlows,
sameKeyRetryIsIdempotentWithoutAskingTheHost,
authorizerFailureRegistersNothing, authenticationFailsBeforeTheHostIsAsked.
server:ktor SecurityRemediationRoutesTest:
f1DeniedRegistrationIs403AndStoresNothing,
f1ClientTransportReportsTheDenial, sameKeyRetryIs204WithoutAskingTheHost,
authorizerFailureIsAGeneric500, registrationKeyMustBeCanonicalBase64.
storage:testing DeviceRegistrationRepositoryContractTest.hasRegisteredDevicesComparesTheExactUserIdAndChangesNothing
(in-memory, SQLDelight, file-backed; removed with hasRegisteredDevices in S1.1).
Remaining limitations. KSecureMessage still does not authenticate human or account ownership: the host's authorizer does. A host that authorizes everyone reopens F1/F2. A denied request consumes its own nonce.
S1.1 changes to this fix: the authorizer now receives the host's
authentication context and no longer a DeviceRegistrationUserState;
hasRegisteredDevices and its contract test are removed (findings N1, N2).
The S1 test names above were kept and adapted.
F3 — Unauthenticated bytes in SessionInitiationId¶
Original severity: MEDIUM.
Root cause. Kodium's X3DH uses only the X25519 half of the 64-byte
ephemeral key; the v1 associated data was initiatorIK || responderIK. The
Ed25519 half of the ephemeral key (and, indirectly, the prekey IDs) fed the
v1 SessionInitiationId unauthenticated. A relay could flip a bit, the
first message still decrypted, the ID was new and not retired: the retired
initiation replay protection was bypassed (session rollback /
desynchronization).
Fix. Session initiation v2 (PreKeyMessage wire type 0x03):
canonical transcript = domain KSecureMessage-SessionInitiation-v2 ‖
sender and recipient address ‖ both identity keys ‖ the whole ephemeral key
‖ signed prekey ID ‖ one-time prekey flag/ID. The transcript is the ratchet
associated data of the whole session and SessionInitiationId v2 =
SHA-256(transcript). Engine acceptSession refuses v1. A repeated v2
PreKeyMessage on an existing session is matched field by field. Session
state format v4 records the initiation version, both identity keys and the
v2 header.
Changed files: P/SessionInitiationId.kt, P/KodiumProtocolEngine.kt,
P/ProtocolEngine.kt, P/SessionState.kt, P/CiphertextMessageCodec.kt,
P/ProtocolConstants.kt, core/model/.../Messages.kt
(PreKeyMessage.initiationVersion, SessionInitiationVersion),
C/SecureMessageClient.kt.
Invariant. Changing any byte a v2 SessionInitiationId depends on makes
the first message fail to decrypt; there is never a second valid ID with an
accepted session for one initiation.
Adversarial tests. core:protocol SessionInitiationV2Test:
f3MutatedEphemeralSigningHalfFailsAcceptance,
f3MutatedSignedPreKeyIdFailsAcceptanceEvenWithTheSameKey,
f3MutatedOneTimePreKeyIdFailsAcceptanceEvenWithTheSameKey,
repeatedInitiationWithAnyChangedFieldIsRejectedOnTheSession,
versionDowngradeOfTheTypeByteIsRejected; SessionInitiationIdTest
v2 vectors (independently computed). client:core
SecurityReviewRegressionTest:
f3MutatedEphemeralSigningHalfCannotBypassRetiredInitiationReplayProtection
(after a replacement retired the first initiation, without one-time
prekey), f3MutatedFirstContactCreatesNothing.
Remaining limitations. Withheld initiations that never reached the device are still accepted as new (unchanged, docs/session-lifecycle.md).
F4 — Unauthenticated sender address on first contact¶
Original severity: MEDIUM.
Root cause. The responder pinned the initiator's identity key under
EncryptedEnvelope.sender, which nothing authenticated: POST /v1/messages
was public and the v1 associated data had no addresses. A malicious client
could build a valid initiation with its own key and claim alice/phone,
poisoning the pin for Alice; a relay could move an honest initiation to
another address.
Fix (two parts).
- Signed submission:
POST /v1/messagesisProtectedEndpoint.SEND_MESSAGE: headerX-KSecureMessage-Devicenames the signer, ServerAuth v1 over the exact body with that device's registered key, thenenvelope.sendermust equal the authenticated device (403 sender_mismatch, nothing queued).SecureMessageTransport.send(envelope, signer); every client submission (send, retry, ACK) is signed. - Transcript binding: the v2 transcript contains sender and recipient address, so a relay that rewrites either fails decryption before any pin.
TOFU order for v2 first contact: pin check (refusal only) → AEAD over the address-bound transcript → pin, in one transaction with the session.
Changed files: S/DeviceAuthenticator.kt (SEND_MESSAGE, path),
S/SecureMessageServer.kt (relay(device, envelope),
EnvelopeSenderMismatchException), P/DeviceAuthentication.kt
(SUBMIT_MESSAGE, encodeDevice/decodeDevice), K/KSecureMessageRoutes.kt,
K/HttpDtos.kt, client/ktor/.../KtorSecureMessageTransport.kt,
client/ktor/.../HttpDtos.kt, C/SecureMessageTransport.kt,
C/SecureMessageClient.kt, plus the F3 files.
Invariant. The server queues an envelope only if its sender is the device that signed the submission; a v2 first contact is accepted and pinned only if sender and recipient address are the ones the initiator bound.
Adversarial tests. server:core DeviceAuthenticationServerTest:
f4SubmissionAsAnotherSenderIsRejectedAndQueuesNothing,
submissionAuthenticationCoversTheExactBodyAndIsNotReusableForOtherEndpoints,
bundleFetchStaysPublicAndSubmissionIsSignedByTheSender. server:ktor
SecurityRemediationRoutesTest: f4SubmissionWhoseSenderIsNotTheSignerIs403AndQueuesNothing,
unsignedOrUnnamedSubmissionIs401, submissionSignatureCoversTheExactBody,
f4ClientTransportCannotSubmitAsAnotherDevice. client:core
SecurityReviewRegressionTest: f4RelayRewritingTheSenderIsRejectedBeforeAnyPin,
f4MaliciousClientClaimingAnotherAddressIsRejectedBeforeAnyPin,
f4RelayRewritingTheRecipientIsRejected,
f4SignedSubmissionStopsAClientSendingAsAnotherDevice. core:protocol
SessionInitiationV2Test: f4RewrittenSenderFailsAcceptance,
f4RewrittenRecipientFailsAcceptance.
Remaining limitations. The server is now the authority for the sender address of a submission. A malicious server can still fabricate a first contact under any address with a key of its choice, as it can hand out a bundle with a key of its choice: first contact trusts the server (TOFU); safety numbers detect it. No sealed sender.
F5/F6 — Log injection¶
Original severity: LOW.
Root cause. UserId/DeviceId accept any UTF-8. Public and
authenticated routes logged DeviceAddress/UserId from path and body with
string interpolation, so %0A could end a log line and forge the next.
Fix. K/LogSanitizer.kt: logSafe quotes and escapes \, ", CR,
LF, TAB, every other C0/C1 control, DEL, U+2028 and U+2029;
DeviceAddress.forLog() / UserId.forLog(). Every route log line uses
them. Identifiers themselves are not restricted or changed.
Invariant. Every log event written by the routes is one line; a request-supplied identifier appears only escaped.
Adversarial tests. server:ktor LogInjectionTest:
logSafeEscapesEveryLineBreakAndControl,
hostileAddressesOnPublicRoutesLogOneLineEachAndAreProcessedUnchanged
(recording SLF4J logger; \n, \r, \r\n, U+2028, U+2029, U+0085, TAB,
NUL on the public challenge and reset-status routes).
Remaining limitations. A log pipeline that unescapes fields reopens the
issue; the exception text of 500 failures is logged as the exception.
F7 — Storage encryption downgrade through the plaintext migration¶
Original severity: LOW.
Root cause. SqlDelightClientStorage.open trusted the plaintext
storage_encryption.format marker. An attacker with write access to the
database could set it to 0, rebuild the sensitive tables in the milestone 8
layout with chosen rows and have them sealed with the legitimate key.
Fix.
- Structural check for
format = 0: no key, key check or rotation state; the five legacy tables in plaintext layout; no record type written only by encrypted storage. OtherwiseStorageEncryptionException.DowngradeRejected. StorageKeyProvider.hasKeys()(never creates anything; Android, Apple, static and test providers).- Provider empty → genuine first migration: key created, migration
intent (record type 13,
E/ClientRecordCipher.kt) sealed with it and committed before migrating. Provider has a key → only a valid intent (opens as type 13 with that key) resumes; otherwiseDowngradeRejected. - The migration transaction re-checks everything, migrates, binds and clears
the intent. A
format = 1database with an intent is refused. - Client schema v16 (
Q/.../15.sqm,storage_encryption.migration_intent), frozen fixtureVersion15Schema;SEALED_COLUMNSscans the intent.
Changed files: Q/kotlin/.../SqlDelightClientStorage.kt,
Q/kotlin/.../LegacyPlaintextMigration.kt (hasPlaintextLayout),
Q/kotlin/.../StorageKeyRotation.kt, Q/sqldelight/.../ClientState.sq,
Q/sqldelight/.../15.sqm, E/StorageKeys.kt, E/ClientRecordCipher.kt,
E/EncryptedRecordFormat.kt, E/StorageEncryptionException.kt,
storage/keyprovider/android/.../AndroidStorageKeyProvider.kt,
storage/keyprovider/apple/.../AppleStorageKeyProvider.kt.
Invariant. A database that was ever bound to a storage key is never
migrated from plaintext again: forcing format back fails closed and
nothing is encrypted.
Adversarial tests. storage:client:sqldelight StorageDowngradeTest:
f7EncryptedDatabaseRewrittenAsPlaintextIsRejected (the full attack),
f7OnlyTheFormatMarkerFlippedIsRejectedTyped,
f7MarkersClearedButPostPlaintextRecordsKeptIsRejected,
f7KeyColumnsOnlyClearedIsRejected,
f7FlippedDatabaseIsRejectedByItsStructureEvenWithAnEmptyProvider,
f7ForgedMigrationIntentIsRejected (key check, garbage, intent of another
key), f7CrashBetweenKeyCreationAndTheIntentFailsClosed,
f7StaticProviderCannotProveAFirstMigration,
genuinePlaintextDatabaseMigratesWithAnEmptyProvider,
encryptedDatabaseCarryingAnIntentIsRejected,
version15DatabaseGainsAnEmptyMigrationIntent. SqlDelightMigrationTest
(frozen milestone 8 database, failedMigrationRollsBackCompletely resumes
with the intent). storage:encryption StorageCipherTest.migrationIntentVector
(independently computed), migrationIntentIsNoKeyCheckAndNeedsItsKey.
storage:testing StorageKeyProviderContractTest.hasKeys*.
Remaining limitations (S1.1: stated precisely, finding D6/D7).
- What S1 protects: a database whose provider holds its key cannot be pushed back through the plaintext migration by editing the database file alone, unless the attacker holds a valid migration intent.
- Intent replay across time: an intent captured while a genuine first migration was interrupted stays valid as long as that storage key exists. Completing the migration clears it in that database only, not in the attacker's copy.
- Intent replay across databases: the intent is bound to the provider key, not to a database; under the same provider (namespace and key) it opens in any reconstructed database. The window closes when that storage key is rotated out and removed. A per-database random ID was not added: it would sit in the same attacker-writable file.
- Provider state is trusted: the check assumes the provider's backing
state is intact.
AndroidStorageKeyProvider.hasKeys()reads the wrapped key file, not the Keystore alias: an attacker who can delete that file in the app sandbox makes the provider look empty, and a downgraded database then passes as a first migration. No protection against modification of both database and provider state. - Not whole-database or provider rollback protection. A crash between key
creation and the intent commit fails closed (manual recovery).
StaticStorageKeyProvidercannot migrate a milestone 8 database (deliberate).
F7 remains partially fixed; the residual risks above are accepted for v0.x and documented, not closed.
Status: PARTIALLY FIXED — RESIDUAL RISK DOCUMENTED (accepted for v0.x).
F8 — Nonce pruned while its request is still fresh¶
Original severity: LOW.
Root cause. Accepted: now − W ≤ ts ≤ now + W. A request with ts is
replayable while server time t ≤ ts + W. Each claim pruned ts < now − W
with its own now; the freshness check used another. A replay checked at
ts + W could claim after a concurrent request checked at ts + W + 1 ms
had pruned the original nonce.
Fix. Monotonic prune watermark P in AuthenticationNonceRepository.claim,
one atomic step: P := max(P, pruneBefore); refuse ts < P; prune < P;
insert. In-memory field; SQLDelight server schema v8 (V/.../7.sqm,
authentication_nonce_watermark, frozen fixture ServerVersion7Schema).
Recovery, rotation and recovery key CAS helpers claim through the same step.
Invariant. Every nonce with timestamp ≥ P is retained and every claim
with timestamp < P is refused; therefore a nonce is refused for the whole
time its request can pass the window, whatever the interleaving.
Adversarial tests. storage:testing AuthenticationNonceRepositoryContractTest
(in-memory, SQLDelight, file-backed): f8InterleavedPruneBeforeReplayClaimIsRejected,
f8NonceIsRetainedForItsWholeReplayLifetime (earliest valid, current,
future-dated ts = now + W), f8ExactPruneBoundary (±1 ms),
f8WatermarkNeverDecreases, f8ConcurrentPruneAndReplayClaimsNeverAcceptAReplay.
server:core NonceLifetimeServerTest.
Remaining limitations. After a backward jump of the server clock,
requests older than the watermark are refused (never accepted twice) until
the clock catches up. A legitimate request can be refused only in the
last clock tick of its window when another request already advanced P.
F9 — Legacy locally initiated session pins the local identity¶
Original severity: LOW (confirmed by 2 of 3 review checkers).
Root cause (confirmed in the source). Engine decrypt accepted a
PreKeyMessage if associatedData.startsWith(message.identityKey) — the
initiator slot. For a session this device initiated, that slot is the
local key. On a pre-milestone-6 session (origin == null) the origin
check was skipped, and the client then pinned message.identityKey for an
unpinned session. A relay could wrap the peer's genuine ratchet message in a
PreKeyMessage naming the victim's own key: the victim pinned itself.
Fix. checkRepeatedInitiation (P/KodiumProtocolEngine.kt): a
PreKeyMessage on an existing session requires that this device is the
responder (responder slot = local key, initiator slot ≠ local key), that
the message names the initiator key and has the session's initiation
version. ProtocolEngine.decrypt gains localIdentityKey. Legacy pins come
only from ProtocolEngine.sessionRemoteIdentityKey(session, localIdentityKey)
(the one non-local slot; null if ambiguous) and only if it equals the
message's key (C/SecureMessageClient.kt pinLegacySession).
Invariant. A remote pin is only ever the authenticated remote identity key of the session; never the local identity; never guessed.
Adversarial tests. client:core SecurityReviewRegressionTest:
f9LocallyInitiatedLegacySessionNeverPinsTheLocalIdentity (frozen pre-S1
fixture), f9ResponderLegacySessionPinsTheAuthenticatedInitiatorKey,
f9AmbiguousLegacySessionFabricatesNoPin, f9ExistingVerifiedPinIsUnchanged.
core:protocol SessionInitiationV2Test:
f9InitiatorNeverAcceptsAPreKeyMessageOnItsOwnSession,
f9LegacyInitiatorSessionRejectsARewrappedMessageNamingItsOwnIdentity,
sessionRemoteIdentityKeyNeverReturnsTheLocalKey.
Remaining limitations (corrected in S1.1, finding D4). The S1 text said
a locally initiated session from before pinning "stays unpinned until an
authenticated new initiation replaces it", but the S1 code refused every
replacement of an unpinned session. Since S1.1: an unpinned session is
replaced only by a v2 initiation naming exactly the identity it was
established with as remote (sessionRemoteIdentityKey), which is then
pinned; any other key, or an undeterminable one, is still refused
(InvalidMessage) and only the application can remove such a session. An
unanswered v1 initiation is replaced by any authenticated v2 initiation
(finding N3).
S1.1 follow-up¶
The S1 re-review passed with residual risks and reported N1–N3 and D1–D7. S1.1 addresses only those. No new wire, cryptographic or record format, no client or server schema change.
N1 — Registration authorizer without the host's request context¶
Severity: MEDIUM.
Root cause. DeviceRegistrationAuthorizer.authorize(request) saw only
the address, the proposed key and userState. The docs suggested account
tokens, sessions or enrollment codes, but nothing carried them from the
HTTP call to the authorizer, so a host could not answer "may this caller
register this device for this UserId?" without side channels
(thread-locals, global request state).
Fix.
S/DeviceRegistrationAuthorizer.kt:fun interface DeviceRegistrationAuthorizer<in C : Any> { suspend fun authorize(context: C, request): DeviceRegistrationAuthorizationResult }.Cis opaque host state (principal, session, OAuth subject, enrollment grant); never logged, serialized, stored, returned or put into a signed or hashed input.SecureMessageServer<C : Any>(storage, clock, deviceRegistrationAuthorizer: DeviceRegistrationAuthorizer<C>, recoveryKeyResetPolicy = null);registerDevice(context: C?, registration, body, authentication). Order: size → proof of possession → existing registration (same keyfalse, other keyConflict, no host call, no context needed) →context == null→NotAuthorized→ authorizer (outside every storage transaction) → atomicregister. An authorizer exception becomesDeviceRegistrationAuthorizationFailedException(fixed message, host exception ascause).K/DeviceRegistrationContextExtractor.kt:fun interface DeviceRegistrationContextExtractor<out C : Any> { suspend fun extract(call: ApplicationCall): C? };fun <C : Any> Route.kSecureMessageRoutes(server: SecureMessageServer<C>, registrationContext: DeviceRegistrationContextExtractor<C>): both required, no default, the type parameter ties extractor and authorizer together.server:corehas no Ktor dependency.- HTTP: no context or
Denied→403 registration_not_authorized; extractor or authorizer exception →500 internal_error, logged only asRegistration authorization failed (<exception class>)(no message, stack, cause or context); same key →204; other key →409. - Sample (
samples/jvm-e2e): DEMO ONLY bearer-token table →DemoPrincipal→ authorizerrequest.address.userId == principal.userId; the clients send the token through their ownHttpClient; the sample checks that Mallory's principal and no principal cannot registeralice/tabletand Alice's principal can.
A normal host wires it like this: its own authentication (Ktor
Authentication, a session, a bearer token) establishes a principal for the
call → the extractor returns that principal, null if the call is not
authenticated → the authorizer allows the registration iff the principal
owns request.address.userId (and the host's device policy agrees).
Changed files: S/DeviceRegistrationAuthorizer.kt,
S/SecureMessageServer.kt, K/DeviceRegistrationContextExtractor.kt (new),
K/KSecureMessageRoutes.kt, samples/jvm-e2e/app/build.gradle.kts,
samples/jvm-e2e/app/.../Main.kt; test support
TestDeviceRegistrationAuthorizer/TestRegistrationPrincipal (server:core,
server:ktor), TestServer.kt, RecordingLogger.kt.
Invariant. Every new registration decision receives the host application's authenticated request context; without one no registration is created.
Adversarial tests. server:core DeviceRegistrationAuthorizationTest:
n1RegistrationAuthorizationReceivesTheAuthenticatedApplicationPrincipal
(the exact context object reaches the authorizer),
n1WrongPrincipalCannotRegisterAnotherUsersDevice,
n1MissingContextIsDeniedAndRegistersNothing (even an allow-all authorizer
is not called), n1MalloryCannotBecomeAlicesRecoveryAuthority (denied
registration → AuthorizerNotRegistered; her own user's device →
CrossUser), sameKeyRetryIsIdempotentWithoutAskingTheHost (also without
context), authorizerFailureRegistersNothing. server:ktor
SecurityRemediationRoutesTest: n1RegistrationAuthorizationReceivesTheAuthenticatedApplicationPrincipal,
n1WrongPrincipalCannotRegisterAnotherUsersDevice,
n1MissingContextIsDeniedAndRegistersNothing (403, then 201 with the
principal, 204 retry without it, 409 other key),
n1HostFailuresLeakNoContext (authorizer and extractor throwing with
secret text: 500 internal_error, secret in neither body nor log, nothing
stored), n1ClientWithTheHostsAuthenticationRegistersThroughTheKtorAdapter.
Sample E2E (verifyPublication).
Remaining assumptions. The host's extractor and authorizer are correct: an extractor that returns a shared principal for unauthenticated calls or an authorizer that ignores the principal reopens F1/F2.
Status: CLOSED by the independent S1.1 re-review.
N2 — Non-atomic userState¶
Severity: LOW.
Root cause. registerDevice read hasRegisteredDevices(userId) before
the authorizer and wrote with a separate atomic register. Two concurrent
first registrations of one user could both be told
USER_HAS_NO_REGISTERED_DEVICES; a policy "the first device may enroll
itself" would admit both, while the value was documented as a fact.
Fix (design: remove, not make atomic). DeviceRegistrationUserState,
DeviceRegistrationAuthorizationRequest.userState and
DeviceRegistrationRepository.hasRegisteredDevices are removed (in-memory,
SQLDelight, the countRegisteredDevicesOfUser query; no schema change).
The authorizer decides only from the host context, the address, the
proposed key and host-owned state. "The user has no devices yet" is not
ownership. The only storage precondition of a registration, "this address
is not registered", is enforced by the atomic register (primary key plus
INSERT OR IGNORE, or the in-memory mutex).
Invariant. No authorization decision depends on a KSecureMessage read outside the registration's atomic write.
Adversarial tests. server:core RegistrationRaceTest (in-memory,
SQLDelight in-memory, file-backed SQLite; every host decision held open by
a barrier until all racing requests are inside the authorizer; gates, no
sleeps): n2ConcurrentFirstDeviceClaimsCannotBothWin (32 concurrent claims
on 32 device IDs of a user with no devices, 16 by Alice's principal, 16 by
Mallory's: exactly Alice's 16 registered, none of Mallory's),
n2LegitimateAndAttackerFirstDeviceRaceOnlyThePrincipalWins (a new user's
legitimate first device vs an attacker's, both decisions open at once, in
both completion orders: the legitimate one registered, the loser not),
n2SameAddressConflictingKeysHaveExactlyOneWinner (32 authorized keys for
one address: one registered, 31 conflicts).
DeviceRegistrationAuthorizationTest.n2NoAuthorizationInputDependsOnExistingDevices
(the request type carries only address and key; the same principal gets
the same decision for a user with and without devices).
storage:testing DeviceRegistrationRepositoryContractTest.concurrentRegistrationsOfOneNewUsersDevicesAreAllStored.
Remaining assumptions. A host that wants a "first device only" policy must enforce it with its own atomic state (for example a single-use enrollment grant in its account database), not with KSecureMessage's registrations.
Status: CLOSED by the independent S1.1 re-review.
N3 — Unanswered v1 initiation deadlocks v2 after the upgrade¶
Severity: LOW.
Root cause. A device could hold a pending (never answered) v1 initiation
from before S1. The collision rule compared its v1 ID with an incoming v2
ID; if v1 was smaller, the v2 initiation lost and was retired, while the
upgraded peer refuses every v1 initiation. The device also kept sending the
v1 initiation. Neither side could make progress, and there was no reset
path. An unpinned pending v1 session additionally refused every v2
initiation (!pinned).
Fix (C/SecureMessageClient.kt, session collision policy only):
- receive: a v2 initiation always replaces an own v1 session awaiting a
reply (
isUnansweredVersion1Initiation), before the pin and collision rules: no ID comparison, also without a pin (nothing was ever established on it; a pin that exists is still checked first). The v1 origin is retired in the same transaction. - send:
usableSessionnever returns such a session.send,retryPendingMessages,encryptRawand session setup fetch a bundle (outside transactions) andinitiateReplacingUnansweredstarts a v2 session, retires the v1 origin and stores the new session with the encryption in one transaction. A pending logical message keeps its ID and sequence and is re-encrypted on v2. - acknowledge: never on such a session (
ackSent = false, nothing written); the sender's retry on the v2 session triggers a new ACK. - Consequence: an unpinned established legacy session whose peer now
sends v2 instead of the v1 repetition that used to pin it is replaced only
by a v2 initiation naming the identity it was established with
(
isLegacySessionIdentity, the F9 check), which is then pinned (D4). - Established v1 sessions (
awaitingReplyfalse) are unchanged. A peer's reply on a v1 initiation it accepted before upgrading still completes it.
Invariant. After the upgrade an unanswered, locally initiated v1 initiation can never block a v2 initiation and is never retransmitted; accepted established v1 sessions continue.
Adversarial tests. client:core SessionLifecycleTest:
n3PendingV1CanNeverBlockIncomingV2AfterUpgrade (v2 ID larger than the v1
origin — the old deadlock order — and smaller, precondition asserted;
converges, bidirectional), n3UnpinnedPendingV1IsReplacedByV2,
n3PinnedPendingV1StillRefusesAnotherIdentity,
n3BothPeersPendingV1ConvergeAfterUpgrade (new frozen fixture
LegacySessionFixture.bobPendingV3; sequential and simultaneous),
n3PendingV1IsNeverRetransmittedAndItsLogicalMessageMovesToV2 (peer never
accepted / accepted before upgrading; same logical ID, one delivery, ACK
clears pending), n3AckIsNeverSentOverPendingV1,
n3DiscardedV1InitiationCannotRevive,
sessionWithoutPinIsNeverReplacedByAnotherIdentity, and
preS1EstablishedSessionsKeepRatchetingAfterTheUpgrade,
preS1InitiationTheResponderNeverAcceptedIsRejected,
preS1InitiationNeverReplacesAVersion2Session (now asserts the exact
error), preS1PendingInitiationContinuesWhenTheResponderAcceptedItBefore
(continuation by the responder's reply). Legacy v1 repetitions in tests are
made with the engine directly (legacyV1Repetition), since the client no
longer sends them; SecurityReviewRegressionTest f9* use the same helper.
Remaining assumptions. Replacing an unanswered v1 initiation that the peer did accept before upgrading drops that peer's in-flight v1 replies (they no longer decrypt); its reliability layer resends them. Peers must still be upgraded together.
Status: CLOSED by the independent S1.1 re-review.
D1–D7 — Documentation¶
| Finding | Correction |
|---|---|
| D1 authorizer mechanisms not reachable | Real host-context API documented with an integration pattern: server-authentication.md, operating-the-server.md, README, sample |
D2 userState presented as authoritative |
Removed from API and docs (N2); the docs say why no KSecureMessage state is passed |
| D3 pre-S1 registrations never host-authorized | operating-the-server.md: impact, read-only audit SQL, offline removal guidance, upgrade checklist; limitation in server-authentication.md |
| D4 identity-trust legacy wording | identity-trust.md and F9 above describe the actual behavior (now: replaced only by its own remote identity; otherwise refused) |
| D5 obsolete session-lifecycle limitations | session-lifecycle.md: stale "no safety numbers / authenticated API / persistent mailbox / encryption at rest" removed; N3 rules and step table updated |
| D6 F7 intent replay understated | storage-encryption.md and F7 above: replay across time and across databases under the same provider key, window closed only by key rotation, no rollback protection |
D7 Android hasKeys() limitation |
storage-key-providers.md: reads the wrapped key file, not the Keystore alias; F7 assumes intact provider state |
Status: FIXED — REVIEWED, NOT INDIVIDUALLY CLOSED (the re-reviews checked the corrected pages but gave no per-finding verdict for D1–D7).
S1.1 mutation testing¶
Each mutation was applied temporarily, the named suites run, the source restored; the working tree diff was compared byte for byte with a snapshot afterwards.
| # | Mutation | Caught by |
|---|---|---|
| S1.1-1 | route drops the host context (passes null / an anonymous context) |
SecurityRemediationRoutesTest.n1* and every route test that registers (76 server:ktor tests) |
| S1.1-2 | sample extractor derives the principal from the target UserId in the path |
sample E2E: alice/tablet with mallory's principal must be refused |
| S1.1-3 | missing context treated as authorized | DeviceRegistrationAuthorizationTest.n1MissingContextIsDeniedAndRegistersNothing, SecurityRemediationRoutesTest.n1MissingContextIsDeniedAndRegistersNothing |
| S1.1-4 | sample authorizer ignores the principal | sample E2E: alice/tablet with mallory's principal must be refused |
| S1.1-5 | host denial ignored, first writer wins | RegistrationRaceTest.n2ConcurrentFirstDeviceClaimsCannotBothWin, n2LegitimateAndAttackerFirstDeviceRaceOnlyThePrincipalWins (in-memory, SQLDelight, file-backed) |
| S1.1-6a | in-memory conditional insert made unconditional | InMemoryRegistrationRaceTest.n2SameAddressConflictingKeysHaveExactlyOneWinner |
| S1.1-6b | SQLDelight INSERT OR IGNORE → INSERT OR REPLACE |
SqlDelight/FileBackedRegistrationRaceTest.n2SameAddressConflictingKeysHaveExactlyOneWinner |
| S1.1-7 | v1 collision uses ordinary ID ordering against v2 | SessionLifecycleTest.n3PendingV1CanNeverBlockIncomingV2AfterUpgrade, n3UnpinnedPendingV1IsReplacedByV2 |
| S1.1-8 | unanswered v1 session reused on send, retry and ACK | n3PendingV1IsNeverRetransmittedAndItsLogicalMessageMovesToV2, n3BothPeersPendingV1ConvergeAfterUpgrade, n3AckIsNeverSentOverPendingV1, n3DiscardedV1InitiationCannotRevive |
| S1.1-9 | established v1 sessions dropped as well | preS1EstablishedSessionsKeepRatchetingAfterTheUpgrade, preS1PendingInitiationContinuesWhenTheResponderAcceptedItBefore, SecurityReviewRegressionTest.f9LocallyInitiatedLegacySessionNeverPinsTheLocalIdentity |
| S1.1-10 | unpinned legacy session replaced by any identity | sessionWithoutPinIsNeverReplacedByAnotherIdentity |
| S1.1-11 | host failure logged with message and stack | SecurityRemediationRoutesTest.n1HostFailuresLeakNoContext |
| S1.1-12 | docs claim F7 closed | checkReleaseConventions since S1.2 (claim f7-partial and the F7 summary row) |
| S1.1-13 | Android hasKeys() limitation removed from the docs |
checkReleaseConventions since S1.2 (claim android-haskeys) |
| S1.1-14 | pre-S1 registration audit warning removed | checkReleaseConventions since S1.2 (claim pre-s1-audit) |
Mutations 12–14 were guarded only by review in S1.1; S1.2 automated them (see Documentation guardrails).
S1.2 follow-up¶
The S1.1 re-review passed with residual risks and reported two more
findings, N4 and N5, plus documentation issues. S1.2 addresses exactly
those: an operator procedure, a tested SQL script, host-boundary hardening
and automated documentation checks. No new protocol, wire format, schema
(client 16, server 8), record type, HTTP path or public API; the
checkKotlinAbi baselines are unchanged.
N5 — Pre-S1 cleanup left the attacker's recovery key authoritative¶
Root cause. The S1.1 guidance removed an unrecognized pre-S1 device's rows and then told the operator to "cancel the reset and have the user rotate or re-register the recovery key". Before S1, such a device could register the user's offline recovery key (or rotate one in, or complete a reset). Removing the device leaves that key ACTIVE, and the attacker holds it: with it alone it can cancel the legitimate user's reset (public R1-signed cancellation), obtain a last-device recovery challenge for any of the user's devices and replace that device's authentication key, and so regain same-user authority. A rotation needs the suspect key's own signature, and re-registration is refused while a key is ACTIVE, so the guidance could not remove it.
Fix (documentation and verification; no code change).
operating-the-server.md now
states that any unrecognized pre-S1 device makes the user's recovery-key
authority suspect, and prescribes an offline cleanup (server stopped,
backup, one BEGIN IMMEDIATE … COMMIT transaction, then a fresh key from a
legitimate device):
- device-scoped rows of each suspicious registration:
device_registration,authentication_nonce,device_prekey_state,available_one_time_prekey,consumed_one_time_prekey, andmailbox_messagerows addressed to it or sent as it; - per affected user:
last_device_recovery_key_stateforced to REVOKED (state = 2,public_key/installed_atNULL, all transition IDs NULL, as the table CHECK requires) atepoch + 1; atLong.MAX_VALUEthe script fails on the NOT NULL constraint and changes nothing (never wraps), and a documented manual variant revokes without an epoch change;last_device_recovery_key_resetandlast_device_recovery_challengerows of the user deleted; - afterwards a legitimate device calls
createLastDeviceRecoveryKey()andregisterLastDeviceRecoveryKey(r2)(registration after revocation, M18/M19).
The audit now shows, besides addresses, each registration's current auth
public key, epoch, installation time and recovery_id / rotation_id /
last_device_recovery_id, the recovery key state with its public key and
transition IDs, pending resets with requester and key/epoch binding, and
outstanding challenges: addresses alone are not enough, because a
pre-S1 recovery may have put an attacker's key on a legitimate address.
Tests (server:ktor PreS1CleanupTest, file-backed SQLite at server
schema 8 with the real constraints, real clients over HTTP; the SQL is read
verbatim from the documentation page):
n5DeviceOnlyCleanupLeavesAttackerRecoveryAuthorityreproduces the finding: with only the device-scoped part, R1 still cancels the reset and takes overalice/phonethrough last-device recovery.n5PreS1CleanupRevokesCompromisedRecoveryAuthority: the device is gone, cannot authenticate, publish or re-register (the host now refuses), its bundle is not found; recovery key REVOKED at epoch 2; reset and challenges gone; the legitimate device's key, epoch, installation time, prekeys and nonces, Bob's state and the mailbox rows between legitimate devices are unchanged.n5OldRecoveryKeyCannotRecoverAfterOfflineCleanup: R1 cannot cancel or query the reset, get a challenge, or use the statement it signed before the cleanup; the phone's key stays.n5LegitimateDeviceCanProvisionFreshRecoveryKeyAfterCleanup: R2 becomes ACTIVE at epoch 3, new challenges carry recovery key epoch 3, R1 stays rejected, R2 recovers a device.n5CleanupAtExhaustedEpochFailsClosedAndRollsBack: atLong.MAX_VALUEthe script aborts with nothing applied; the manual variant revokes, R1 is dead, and a new registration getsEPOCH_EXHAUSTED.n5AuditQueriesRunOnSchemaV8: the audit queries run and expose the attacker's key and the planted recovery key.
S1.3 replaced the in-page script by one script file with guards and
verification and narrowed its scope (N6, INFO-3); the N5 tests now run that
file with the sqlite3 shell.
Status: CLOSED by the independent S1.2 re-review.
N4 — Host errors bypassed the exception wrapper¶
Root cause. The authorizer call in SecureMessageServer.registerDevice
and the extractor call in the registration route caught Exception only.
An AssertionError, NotImplementedError or other Error from host code
reached Ktor's default handler, which logs the message and stack (and in
development mode can show them), although host failures may carry the
request's authentication context.
Fix. One policy at both host boundaries (S/DeviceRegistrationAuthorizer.kt
runHostRegistrationBoundary, K/KSecureMessageRoutes.kt
runHostExtractionBoundary, both internal):
CancellationException: rethrown unchanged (coroutine cancellation).VirtualMachineError: rethrown unchanged in S1.2. That was too broad: host code can throwInternalErrororUnknownErrorwith its own message, and the class of aCancellationExceptionis no proof of cancellation. S1.3 narrows both (N7).- every other
Throwable(exceptions,AssertionError,NotImplementedError,LinkageError, …): wrapped inDeviceRegistrationAuthorizationFailedException(fixed message; the original is itscausefor the host's own diagnostics). The route answers500 {"error":"internal_error"}, also in development mode, and logsRegistration authorization failed (<class name>)without passing any throwable to the logger: no message, stack, cause chain or context. Nothing is registered.
Tests: server:core DeviceRegistrationAuthorizationTest.n4AuthorizerErrorIsWrappedWithoutMessage
(the S1.2 tests n4CancellationIsRethrown, n4VirtualMachineErrorIsNotWrapped
and n4BoundaryPolicy were replaced by the n7… tests of S1.3); server:ktor SecurityRemediationRoutesTest.n4AuthorizerErrorLeaksNoMessageOrStack,
n4ExtractorErrorLeaksNoMessageOrStack (SECRET-A/B/C in an
IllegalStateException, NotImplementedError and AssertionError,
development mode on: neither body nor log contains them, no throwable is
logged, nothing is registered; since S1.3 in development and production
mode, with the N7 cases added).
Status: CLOSED by the independent S1.3 final re-review (the S1.2 re-review found it only partially fixed; N7 completed the boundary policy).
Documentation guardrails¶
checkReleaseConventions (part of check and therefore of build) now
fails if a guarded security statement disappears. Each statement is a
marked region (<!-- ksm-security-claim:<id> -->) or an operator SQL block
(<!-- ksm-sql:<id>:begin -->) that must keep its required tokens:
| Claim | Page | Guarded content |
|---|---|---|
f7-partial |
this page | F7 is PARTIALLY FIXED — RESIDUAL RISK DOCUMENTED (never "CLOSED"), and so is the F7 summary row |
android-haskeys |
storage-key-providers.md | AndroidStorageKeyProvider.hasKeys() reads the wrapped key file, not the Keystore alias |
pre-s1-audit |
operating-the-server.md | pre-S1 registrations never host-authorized, audit, addresses alone are not enough, keys and transition IDs |
pre-s1-recovery-key-cleanup |
same | removing only the device is insufficient, key compromised, server stopped, one transaction, revocation, reset/challenge deletion, fresh key; never "rotate or re-register" |
SQL pre-s1-audit, pre-s1-cleanup |
same | every audited column; every cleanup statement, including the REVOKED update with its non-wrapping epoch |
The SQL blocks are additionally executed by PreS1CleanupTest.
Stale statements corrected¶
- Client schema is 16, not 15 (operating-the-server.md,
SqlDelightClientStorageKDoc); the M25 page now says its schema versions were those at M25. - The status wording at the top of this page matches the summary table (closed only by the independent re-review).
SecureMessageClient.registerDeviceKDoc no longer says the server trusts the first registration (host authorization since S1).- Pre-S1 guidance in security-review.md, server-authentication.md, device-recovery.md and the README points to the recovery-key cleanup.
S1.2 mutation testing¶
Each mutation was applied alone, the named test or check was run, and the mutation was reverted (checksums compared).
| # | Mutation | Caught by |
|---|---|---|
| S1.2-1 | cleanup leaves the recovery key ACTIVE (UPDATE removed) | PreS1CleanupTest (4 tests), checkReleaseConventions |
| S1.2-2 | cleanup keeps the pending reset | n5PreS1CleanupRevokesCompromisedRecoveryAuthority, n5OldRecoveryKeyCannotRecoverAfterOfflineCleanup, checkReleaseConventions |
| S1.2-3 | cleanup keeps the challenges | n5PreS1CleanupRevokesCompromisedRecoveryAuthority, checkReleaseConventions |
| S1.2-4 | docs say "rotate or re-register" instead of revoke | checkReleaseConventions (forbidden phrase, missing SET state = 2), 3 PreS1CleanupTest tests |
| S1.2-5 | audit query omits the auth public key and transition IDs | n5AuditQueriesRunOnSchemaV8, checkReleaseConventions |
| S1.2-6 | authorizer boundary catches Exception only |
n4AuthorizerErrorIsWrappedWithoutMessage, n4BoundaryPolicy, n4AuthorizerErrorLeaksNoMessageOrStack |
| S1.2-7 | extractor boundary catches Exception only |
n4ExtractorErrorLeaksNoMessageOrStack, n4CancellationIsRethrown (route) |
| S1.2-8 | host failure logged with the throwable | n1HostFailuresLeakNoContext, n4AuthorizerErrorLeaksNoMessageOrStack, n4ExtractorErrorLeaksNoMessageOrStack |
| S1.2-9a | authorizer boundary wraps cancellation | server:core n4CancellationIsRethrown, n4BoundaryPolicy (end to end the route still rethrows a wrapped cancellation) |
| S1.2-9b | extractor boundary wraps cancellation | server:ktor n4CancellationIsRethrown |
| S1.2-10 | F7 claim region removed / F7 row says CLOSED | checkReleaseConventions |
| S1.2-11 | Android hasKeys() limitation removed |
checkReleaseConventions |
| S1.2-12 | pre-S1 audit warning removed | checkReleaseConventions |
| S1.2-13 | fresh-recovery-key provisioning step removed | checkReleaseConventions |
S1.3 follow-up¶
The S1.2 re-review passed with residual risks, closed N5 and reported two
LOW findings, N6 and N7, plus INFO-3 (cleanup side effects). S1.3 addresses
exactly those: internal server exception handling, the operator script and
documentation, tests and release checks. No protocol, wire format, schema
(client 16, server 8), record type, HTTP path, crypto domain or public API
change; the checkKotlinAbi baselines are unchanged.
N6 — Pre-S1 cleanup not robust against operator mistakes¶
Root cause. Two independent ones. (1) The page said to open the
database with the sqlite3 shell and run the script. Pasted into the
interactive prompt, .bail on has no effect: the shell continues after a
failed statement, and the script's COMMIT then commits whatever ran
(n6InteractiveExecutionCommitsPartialCleanupRootCause reproduces it with
sqlite3 -interactive). (2) A listed device that does not exist (a typo,
alice/evl for alice/evil) matched no row: the script "succeeded",
revoked the user's recovery key and left the attacker's device registered.
Fix.
- One executable source of truth:
operator/ksecuremessage-pre-s1-cleanup.sql, shown in the page by inclusion (no copy), run by the tests. - One supported invocation,
sqlite3 -bail /path/to/server.db < ksecuremessage-pre-s1-cleanup.sql; the page forbids interactive pasting and says why. - Guards inside the transaction, after the input and before the first
DELETE/UPDATE, as namedCHECKconstraints of a temporary table:server_schema_is_version_8,at_least_one_device_listed(the unedited file is refused),every_listed_device_is_registered,no_unlisted_exhausted_epoch,every_exhausted_user_is_listed_active_and_exhausted; duplicates fail on the input table's primary key. Any failure: non-zero exit, rollback, nothing changed. - Recovery keys: only ACTIVE rows are revoked (
WHERE state = 1), so an already REVOKED user is unchanged (idempotent); several devices of one user cause one transition. The no-epoch-change variant for an exhausted epoch applies only to users the operator lists inksm_exhausted_user(no hand-editedUPDATEany more); all others get epoch + 1. - Post-cleanup verification in the same run (the same list, nothing typed
twice): one
name|violationsline per check, aCHECKthat fails the run on any violation, and the final lineksm-pre-s1-cleanup: verification passed; the page additionally requires re-running the audit in a fresh session before the restart.
Tests (server:ktor PreS1CleanupTest, the real sqlite3 binary with
the documented command; the test fails, never skips, if sqlite3 is not on
PATH; every failure case compares a snapshot of every server table):
n6MissingListedDeviceAbortsWithoutChanges,
n6DuplicateListedDeviceAbortsWithoutChanges, n6EmptyCleanupIsRejected
(also the file as shipped), n6CleanupFailureRollsBackEverything (a
failing statement just before COMMIT), n6SchemaGuardRejectsAnotherServerSchema,
n6ExhaustedEpochAbortsUnlessListed, n6ListingANonExhaustedUserAsExhaustedAborts,
n6ExhaustedEpochPathAffectsOnlyListedUsers (REVOKED, epoch stays
Long.MAX_VALUE, R1 dead, EPOCH_EXHAUSTED afterwards; another user in the
same run gets epoch + 1), n6MultipleDevicesOfOneUserIncrementEpochOnce,
n6MultipleUsersRemainIsolated, n6AlreadyRevokedUserIsUnchanged,
n6VerificationOutputReportsZeroViolations, n6VerificationFailsLoudly,
n6GuardRunsBeforeFirstDestructiveStatement,
n6OperatorPageDocumentsExactlyTheTestedInvocation and
n6InteractiveExecutionCommitsPartialCleanupRootCause. The N5 tests run the
same file.
Status: CLOSED by the independent S1.3 final re-review.
INFO-3 — Cleanup side effects¶
The cleanup removes authority and reachability, not history:
consumed_one_time_prekeyis kept. Deleting it let a legitimate device returning to a known address publish a one-time prekey ID that had already been handed out, and the server handed it out again. Publication skips consumed IDs, so keeping the tombstones never blocks a returning device (itsdevice_prekey_stateis deleted, so it may publish a new identity key).mailbox_messagerows sent as a listed address are kept, for a wholly malicious address as for a known one: they are opaque ciphertext that confers no authority, end-to-end authenticity is decided by the recipient's session and identity trust, and a known address may have legitimate envelopes in flight. Rows addressed to a listed registration are deleted (the invalidated device instance); senders resend pending messages.device_registration,authentication_nonce,device_prekey_stateandavailable_one_time_prekeyof listed registrations, and the recovery key state, reset and challenges of affected users, are handled as before.
Tests: info3ConsumedOneTimePreKeyIdIsNotReissued (a one-time prekey
of alice/phone handed to Bob, cleanup, the phone re-registers under host
authorization and publishes again: the consumed ID is never handed out
again) and info3KnownAddressWithAttackerKeyCanReturn (the attacker took
over alice/phone with R1 before S1: after the cleanup the attacker's key
is not registered, the recovery key is REVOKED, the phone's earlier
envelope to Bob is delivered, envelopes to the phone are gone, the phone
re-registers its own key with the host's authorization, publishes, never
re-issues a consumed ID and provisions a fresh recovery key at epoch 3);
n5PreS1CleanupRevokesCompromisedRecoveryAuthority checks the kept
tombstones and sent-as envelopes of alice/evil.
N7 — Host-thrown cancellation and VM errors leaked their message¶
Root cause. Both host boundaries rethrew every CancellationException
and every VirtualMachineError unchanged, and the route's failure handler
rethrew a CancellationException cause. Host code can throw
CancellationException("…") while its coroutine is still active, or
InternalError("…"); Ktor then handled an uncaught throwable, and on a
real CIO engine its message could become the 500 body and appear in the
log. The class alone is no proof of cancellation or of a JVM condition.
Fix (N7). One policy, implemented as classifyHostFailure
(S/DeviceRegistrationAuthorizer.kt, used by runHostRegistrationBoundary)
and mirrored as classifyExtractionFailure (K/KSecureMessageRoutes.kt,
used by runHostExtractionBoundary): sharing one function across the two
modules would make it public API, so both are internal and tested to
classify every throwable identically:
- genuine coroutine cancellation (a
CancellationExceptionwhile the calling coroutine is no longer active): propagates unchanged, preserving structured concurrency; KSecureMessage neither answers nor logs it; OutOfMemoryErrorandStackOverflowError: process-health failures, propagate unchanged and are never turned into an ordinary HTTP answer. KSecureMessage does not sanitize their message, and what the engine does with them after they leave its boundary is outside its control;- everything else is sanitized: a
CancellationExceptionthrown while the coroutine is still active (also with a secret cause),InternalError,UnknownError, otherVirtualMachineErrors,LinkageErrorand every otherThrowableare wrapped inDeviceRegistrationAuthorizationFailedException(fixed message) and answered500 {"error":"internal_error"}, logged by class name only, with nothing registered.
What this policy does not cover (S1.3 final re-review, informational
residuals): KSecureMessage does not sanitize what it deliberately
propagates. After genuine cancellation, a message or cause host code
attached to the cancellation may be logged by the runtime or server
downstream; host code must not place secrets in cancellation exception
messages or causes. An OutOfMemoryError or StackOverflowError
constructed by host code with a sensitive message may reach the engine's
HTTP and log handling unchanged; host code must not use them as control
flow or attach secrets to their messages. What happens after such a
process-health failure leaves the boundary is outside KSecureMessage's
sanitization guarantee.
The route's failure handler no longer rethrows a CancellationException
cause: the boundaries hand it only failures they sanitized.
Tests: server:core DeviceRegistrationAuthorizationTest.n7SyntheticCancellationAndHostVmErrorsAreSanitized,
n7RealCancellationPropagates (the authorizer cancels its own coroutine:
the job ends cancelled, the cancellation is not wrapped, nothing is
registered), n7FatalVmErrorPolicyIsPreserved (plain OutOfMemoryError /
StackOverflowError objects; nothing is exhausted), n7BoundaryPolicy;
server:ktor SecurityRemediationRoutesTest.n4AuthorizerErrorLeaksNoMessageOrStack
and n4ExtractorErrorLeaksNoMessageOrStack (now also synthetic
cancellation, cancellation with a SECRET-CAUSE, InternalError,
UnknownError, in development and production mode),
n7RealCancellationPropagates, n7ExtractorAndAuthorizerClassifyIdentically,
n1HostFailuresLeakNoContext; and on a real CIO engine (not the test host)
HostBoundaryCioTest.n7ExtractorFailuresAreSanitizedOnCio /
n7AuthorizerFailuresAreSanitizedOnCio (development mode off and on: body
exactly {"error":"internal_error"}, no secret in body or log).
Status: CLOSED by the independent S1.3 final re-review.
S1.3 documentation guardrails¶
checkReleaseConventions additionally guards:
| Claim | Where | Guarded content |
|---|---|---|
pre-s1-recovery-key-cleanup |
operating-the-server.md | the exact script command, "Do not paste the cleanup statements interactively", why, the guards, mandatory post-cleanup verification, restart only afterwards; forbids "open the database with the sqlite3 shell", "paste into", a hand-edited UPDATE |
SQL pre-s1-cleanup-script |
same | the page includes the script file, holds no copy of its statements |
pre-s1-cleanup |
the script file | .bail on, transaction, input table keys, every guard, every statement, verification; forbids deleting tombstones or envelopes sent as a cleaned address; the guard runs after the input and before the first DELETE/UPDATE |
pre-s1-audit |
operating-the-server.md | also: an address alone never clears a registration whose key cannot be established as legitimate |
host-boundary-throwables |
operating-the-server.md, this page | genuine cancellation propagates, active cancellation, InternalError, UnknownError sanitized, OOM/SOE policy; no page may attribute a throwable's message to the JVM |
n8-residual, n9-residual (release preparation) |
this page | N8 and N9 stay ACCEPTED — RESIDUAL RISK DOCUMENTED with their consequence (queued envelopes may still be delivered; tombstoned uploads are skipped); never "FIXED" or "CLOSED", and so do their summary rows |
These checks are regression guards over required and forbidden tokens, not a semantic proof: a rewrite that keeps the tokens but changes the meaning is not caught, so security-relevant documentation changes still need review.
S1.3 mutation testing¶
Each mutation was applied alone, the named test or check was run, and the mutation was reverted (checksums compared).
| # | Mutation | Caught by |
|---|---|---|
| S1.3-1 | device-existence guard removed from the script | n6MissingListedDeviceAbortsWithoutChanges, n6InteractiveExecutionCommitsPartialCleanupRootCause, checkReleaseConventions (after this run the guard's subquery became a required token) |
| S1.3-2 | guard moved after the first destructive DELETE |
checkReleaseConventions (order), n6GuardRunsBeforeFirstDestructiveStatement and 15 more PreS1CleanupTest tests (the guard then refuses every run) |
| S1.3-3 | page allows pasting into the sqlite3 prompt | checkReleaseConventions, n6OperatorPageDocumentsExactlyTheTestedInvocation |
| S1.3-4 | post-cleanup verification removed from the script | n6VerificationOutputReportsZeroViolations, n6VerificationFailsLoudly, n6ExhaustedEpochPathAffectsOnlyListedUsers, checkReleaseConventions |
| S1.3-5 | consumed one-time prekey tombstones deleted again | info3ConsumedOneTimePreKeyIdIsNotReissued, info3KnownAddressWithAttackerKeyCanReturn, n5PreS1CleanupRevokesCompromisedRecoveryAuthority, checkReleaseConventions |
| S1.3-6 | envelopes sent as a cleaned address deleted again | n5PreS1CleanupRevokesCompromisedRecoveryAuthority, info3KnownAddressWithAttackerKeyCanReturn, checkReleaseConventions |
| S1.3-7 | no-epoch-change revocation applied to every affected user | n6ExhaustedEpochPathAffectsOnlyListedUsers, checkReleaseConventions |
| S1.3-8 | every CancellationException rethrown (both boundaries) |
server:core n7BoundaryPolicy, n7SyntheticCancellationAndHostVmErrorsAreSanitized; server:ktor n4…LeaksNoMessageOrStack (2), HostBoundaryCioTest (2) |
| S1.3-9 | synthetic cancellation logged with its throwable | n4AuthorizerErrorLeaksNoMessageOrStack, n4ExtractorErrorLeaksNoMessageOrStack, HostBoundaryCioTest (2) |
| S1.3-10 | every VirtualMachineError rethrown (both boundaries) |
as S1.3-8 |
| S1.3-11 | InternalError bypasses sanitization (both boundaries) |
as S1.3-8 |
| S1.3-12 | extractor boundary classifies differently from the authorizer boundary | n7ExtractorAndAuthorizerClassifyIdentically, n4ExtractorErrorLeaksNoMessageOrStack, HostBoundaryCioTest.n7ExtractorFailuresAreSanitizedOnCio |
| S1.3-13 | genuine cancellation wrapped into a 500 (both boundaries) | server:core n7RealCancellationPropagates, n7BoundaryPolicy; server:ktor n7RealCancellationPropagates |
| S1.3-14 | host failure text logged | n1HostFailuresLeakNoContext, n4…LeaksNoMessageOrStack (2) |
| S1.3-15 | script-only warning removed from the page | checkReleaseConventions, n6OperatorPageDocumentsExactlyTheTestedInvocation |
| S1.3-16 | missing-device guard removed from script and page | n6MissingListedDeviceAbortsWithoutChanges, n6InteractiveExecutionCommitsPartialCleanupRootCause, checkReleaseConventions |
| S1.3-17 | post-cleanup verification step removed from the page | checkReleaseConventions |
S1.3 final re-review¶
The independent final re-review of S1.3 concluded: N6 CLOSED, N7 CLOSED (and with it N4), F7 accurately documented as a residual, two new LOW findings N8 and N9 in the INFO-3 cleanup changes, and "passed with residual risks; ready for public v0.x with documented residual risks". N8 and N9 do not restore attacker authority. They are accepted and documented for v0.1.0, not fixed; this release changes no code for them.
N8 — Queued envelopes from cleaned sender addresses¶
Severity: LOW. Status: ACCEPTED — RESIDUAL RISK DOCUMENTED.
Precondition. Before S1, a malicious or suspect device registered
under some address successfully submitted an envelope, and that envelope
was still queued in mailbox_message when the operator ran the pre-S1
cleanup for that address.
Behavior. The cleanup (INFO-3) deliberately keeps mailbox rows sent as a cleaned address and deletes only rows addressed to it. Such an already-queued envelope may still be delivered after the cleanup. The cleanup does not recall queued messages.
Boundaries.
- The cleanup removes the registration: the removed key can no longer
submit new authenticated envelopes (
POST /v1/messagesneeds ServerAuth by the registered sender since S1). - Keeping the row restores no server authority: it is opaque ciphertext that was accepted before the cleanup, and holding it grants no registration, recovery or recovery key authority.
- The relay's sender field does not authenticate end-to-end content. The recipient still applies its session, identity pin and session initiation v2 transcript checks; a message that fails them is rejected as before.
- For an exact device address the recipient has never talked to, a valid
first-contact message can create a new identity pin in state
UNVERIFIED, as any first contact does (TOFU). - It cannot replace the pinned identity of another address through the
relay sender field: a different key for a pinned address fails with
IdentityChanged, and the v2 transcript binds both addresses. - Applications should treat an unexpected first contact from a cleaned address after the remediation as unverified and suspicious until the users compare safety numbers.
Why it is accepted. Deleting these rows would need either deleting legitimate in-flight envelopes of known addresses whose authentication key was compromised (the relay cannot tell them apart), or an additional operator input classifying each address as "entirely malicious" versus "legitimate address with a compromised key". S1.3 chose not to add another error-prone operator classification to a security-critical offline procedure and not to destroy legitimate queued messages. This is an intentional availability/security tradeoff for v0.1.0, not a claim that it is the best choice for every deployment; an operator who knows an address was entirely malicious can decide to handle its queued rows separately.
Tests (they confirm the documented residual behavior, not a fix):
info3KnownAddressWithAttackerKeyCanReturn (the earlier envelope is
delivered after the cleanup) and
n5PreS1CleanupRevokesCompromisedRecoveryAuthority (sent-as envelopes of
alice/evil are kept).
N9 — Consumed one-time prekey tombstone poisoning¶
Severity: LOW. Status: ACCEPTED — RESIDUAL RISK DOCUMENTED.
Precondition. Before S1, an attacker controlled (squatted or took over) a device address, published many one-time prekey IDs for it and had them consumed through public bundle fetches.
Behavior. The cleanup keeps consumed_one_time_prekey tombstones
(INFO-3), which is correct: a consumed ID must never be handed out again.
When the legitimate device later publishes one-time prekeys whose IDs are
tombstoned, the server skips them silently
(prekey-publication.md). The client believes its
one-time prekeys were published and its local target is satisfied, while
the server may hold no one-time prekey for the device.
Impact. New sessions with that device are then set up without a one-time prekey (the X3DH-style key agreement runs without the one-time prekey term, which the protocol allows). That removes the extra protection the single-use key gives the initial message: its forward secrecy then depends on the signed prekey's lifetime (rotation, grace period, deletion) instead of a key deleted after one use. Replay of an initiation the device already accepted is still rejected by the retired-initiation set (session-lifecycle.md).
N9 does not restore attacker device authority, does not reveal the plaintext of existing sessions, does not break established sessions and does not allow a consumed one-time prekey to be reused. It is not a loss of end-to-end encryption.
Likely future remediation (not v0.1.0 behavior; a behavioral and API change that needs its own milestone and review): prekey publication reports the IDs it rejected because they are permanently consumed, the client retires those local IDs, allocates fresh, monotonically increasing IDs and continues replenishment.
Tests (they confirm the documented residual behavior, not a fix):
info3ConsumedOneTimePreKeyIdIsNotReissued and
info3KnownAddressWithAttackerKeyCanReturn (consumed IDs stay tombstoned
and are never handed out again).
Cleanup: operational boundaries (N6)¶
N6 is closed for the documented procedure: the one script file run
non-interactively as sqlite3 -bail /path/to/server.db <
ksecuremessage-pre-s1-cleanup.sql fails closed under the SQL errors the
tests provoke (guard failures, constraint failures, a failing statement
before COMMIT). Pasting it into an interactive prompt is unsafe and
unsupported. There is no claim that the script is safe under every
possible sqlite3 invocation.
The post-cleanup verification runs after COMMIT. A verification failure
fails the run and reports the violation, but it does not undo the committed
cleanup; the operator restores the backup taken before the run
(operating-the-server.md). This
is an operational residual, not evidence that N6 is open under the
documented procedure.
N7: informational boundaries¶
Documented in the N7 fix above: genuine cancellation, OutOfMemoryError
and StackOverflowError propagate by design, so a secret that host code
attaches to them is outside KSecureMessage's sanitization guarantee. Host
code must not put secrets into cancellation messages or causes and must
not use those errors as control flow.
Version changes¶
Kept apart on purpose:
| Layer | Before S1 | After S1 |
|---|---|---|
| Maven version | 0.1.0-SNAPSHOT |
unchanged |
| HTTP API v1 paths | — | unchanged; semantics changed: registration needs host authorization (403 registration_not_authorized); POST /v1/messages needs ServerAuth + X-KSecureMessage-Device (401, 403 sender_mismatch, 400 invalid_envelope) |
| ServerAuth signed input | v1 | v1, unchanged (new endpoint only) |
| Ciphertext wire format | v1, types 0x01, 0x02 |
v1, new type 0x03 = PreKeyMessage with initiation v2 |
| Session initiation | v1 (AD = identity keys) | v2 for every new initiation (AD = transcript); v1 only continues existing sessions |
SessionInitiationId |
v1 domain …-SessionInitiation-v1 |
v2 domain …-SessionInitiation-v2 (SHA-256 of the transcript); v1 kept for stored IDs |
| Session state (local) | format 3 | format 4 (v1–3 still read) |
SecurePayload frame, ACK |
v1 | unchanged |
| KSMR record format | v1, types 1–12 | v1, new type 13 (migration intent) |
| Client SQLDelight schema | 15 | 16 (15.sqm) |
| Server SQLDelight schema | 7 | 8 (7.sqm) |
| Recovery, rotation, reset, safety number crypto formats | frozen | unchanged |
Unchanged: established-session ratchet messages (type 0x01), the
SecurePayload frame and ACK, the KSMR record format and existing record
types, ServerAuth v1, device recovery / rotation / last-device recovery /
recovery key lifecycle / reset formats and vectors, safety numbers, X3DH and
ratchet info strings.
Intentionally changed: session initiation / first-contact version,
sender-address authentication, SessionInitiationId derivation, device
registration authorization semantics, message submission authentication,
nonce retention, plaintext migration eligibility.
Upgrade behavior¶
- Established sessions survive and keep ratcheting; no new X3DH.
- A v1 initiation the peer never accepted is rejected. Since S1.1 the initiator never sends its unanswered v1 initiation again: the next send or retry starts a v2 session, and an incoming v2 initiation always replaces it (finding N3). A peer's reply on a v1 initiation it accepted before upgrading still completes it.
- A pre-S1 peer cannot decode type
0x03, and a pre-S1 client cannot submit to an S1 server. Upgrade servers and clients together. There is no automatic v1 fallback that a relay could force. - Servers: construct
SecureMessageServerwith aDeviceRegistrationAuthorizer<C>and pass aDeviceRegistrationContextExtractor<C>tokSecureMessageRoutes(S1.1); migrate the server database to schema 8 beforeopen; audit registrations from before S1 (see the checklist in operating-the-server.md). - Clients: pending messages of a device without its device authentication key cannot be submitted (they stay pending).
Public API changes¶
S1.1 (deliberate, breaking; checkKotlinAbi baselines updated):
server:core:DeviceRegistrationAuthorizer→DeviceRegistrationAuthorizer<in C : Any>withauthorize(context: C, request);SecureMessageServer→SecureMessageServer<C : Any>;registerDevice(context: C?, registration, body, authentication);DeviceRegistrationAuthorizationRequest(address, proposedAuthenticationPublicKey)(nouserState);DeviceRegistrationUserStateremoved; newDeviceRegistrationAuthorizationFailedException.server:ktor: newDeviceRegistrationContextExtractor<out C : Any>;kSecureMessageRoutes(server: SecureMessageServer<C>, registrationContext).storage:core:DeviceRegistrationRepository.hasRegisteredDevicesremoved.client:core: no public API change (N3 is internal session policy).
S1:
server:core: newDeviceRegistrationAuthorizer,DeviceRegistrationAuthorizationRequest,DeviceRegistrationUserState(removed in S1.1),DeviceRegistrationAuthorizationResult,EnvelopeSenderMismatchException;SecureMessageServer(storage, clock, deviceRegistrationAuthorizer, recoveryKeyResetPolicy = null)(clock no longer defaulted);relay(device, envelope);ProtectedEndpoint.SEND_MESSAGE,ProtectedEndpoint.path(address).storage:core:DeviceRegistrationRepository.hasRegisteredDevices(removed in S1.1),DeviceRegistrationException.NotAuthorized;AuthenticationNonceRepository.claimcontract (watermark).core:model:SessionInitiationVersion,PreKeyMessage.initiationVersion.core:protocol:ProtocolEngine.initiateSession(localIdentity, localAddress, bundle),acceptSession(localIdentity, localAddress, …),decrypt(session, message, localIdentityKey),sessionRemoteIdentityKey;SessionInfo.initiationVersion;SessionInitiationId.v2Of;ServerApiPaths.SUBMIT_MESSAGE,encodeDevice,decodeDevice;CiphertextMessageCodec.TYPE_PREKEY_MESSAGE_V2(internal API).client:core:SecureMessageTransport.send(envelope, signer);SecureMessageTransportException.DeviceRegistrationNotAuthorized,EnvelopeSenderRejected.storage:encryption:StorageKeyProvider.hasKeys(),StorageEncryptionException.DowngradeRejected,ClientRecordCipher.sealMigrationIntent/verifyMigrationIntent(internal API).
Mutation testing¶
Each mutation was applied temporarily, the named suites run, the source restored; the working tree was compared with a snapshot afterwards.
| # | Mutation | Caught by |
|---|---|---|
| 1 | authorizer bypassed for a second device | DeviceRegistrationAuthorizationTest.f1FakeDeviceCannotJoinAnExistingUser |
| 2 | authorizer bypassed for a first device | DeviceRegistrationAuthorizationTest.f1FakeFirstDeviceOfAUserIsDenied |
| 3 | denial still inserts the registration | f1FakeDeviceCannotJoinAnExistingUser, f1FakeFirstDeviceOfAUserIsDenied |
| 4 | recovery accepts an unregistered authorizer | DeviceRecoveryServerTest.authorizerAndTargetMustBeRegistered, f1FakeDeviceCannotJoinAnExistingUser |
| 5 | sender address omitted from the transcript | SecurityReviewRegressionTest.f4RelayRewritingTheSenderIsRejectedBeforeAnyPin, f4MaliciousClientClaimingAnotherAddressIsRejectedBeforeAnyPin, SessionInitiationIdTest v2 vectors |
| 6 | unauthenticated ephemeral bytes (identity-key-only AD) | f3MutatedEphemeralSigningHalfFailsAcceptance, f3MutatedEphemeralSigningHalfCannotBypassRetiredInitiationReplayProtection |
| 7 | initiation ID from raw message bytes | f3MutatedEphemeralSigningHalfCannotBypassRetiredInitiationReplayProtection, SessionLifecycleTest (replacement, collision) |
| 8 | TOFU pin written before transcript authentication (same transaction) | Not independently observable because pin and session persist atomically; the adversarial no-pin-after-auth-failure tests cover the security property (f4RelayRewritingTheSenderIsRejectedBeforeAnyPin, f4MaliciousClientClaimingAnotherAddressIsRejectedBeforeAnyPin, f3MutatedFirstContactCreatesNothing). |
| 9 | v1 fallback (v1 initiation accepted with v1 AD) | SessionInitiationV2Test.versionDowngradeOfTheTypeByteIsRejected, SessionLifecycleTest.preS1InitiationTheResponderNeverAcceptedIsRejected |
| 10 | raw CR/LF in logs | LogInjectionTest (both) |
| 11 | format rollback migrates although the provider holds a key | StorageDowngradeTest.f7EncryptedDatabaseRewrittenAsPlaintextIsRejected, f7CrashBetweenKeyCreationAndTheIntentFailsClosed, f7StaticProviderCannotProveAFirstMigration |
| 12 | structural check skipped | StorageDowngradeTest.f7FlippedDatabaseIsRejectedByItsStructureEvenWithAnEmptyProvider |
| 13 | watermark refusal removed (in-memory) | InMemoryAuthenticationNonceRepositoryTest.f8*, NonceLifetimeServerTest.f8ReplayAfterAConcurrentPruneAtTheWindowEdgeIsRejected |
| 14 | pre-S1 claim (SQLDelight) | SqlDelight/FileBackedAuthenticationNonceRepositoryTest.f8* |
| 15 | engine role check removed | f9LocallyInitiatedLegacySessionNeverPinsTheLocalIdentity, f9InitiatorNeverAcceptsAPreKeyMessageOnItsOwnSession, f9LegacyInitiatorSessionRejectsARewrappedMessageNamingItsOwnIdentity |
| 16 | sessionRemoteIdentityKey guesses when ambiguous |
sessionRemoteIdentityKeyNeverReturnsTheLocalKey |
Mutation 9 first survived at the client level because the legacy test's responder lacked the prekeys the initiation named (it failed for another reason); the fixture now includes them and the test checks the exact error.
Remaining limitations (overall)¶
- The host application authenticates and authorizes
UserIdmembership; KSecureMessage still does not prove human or account identity. - The server sees metadata and vouches for sender addresses; no sealed sender. First contact trusts the server (TOFU); manually compared safety numbers detect key substitution.
- No formal verification; Kodium provides the X3DH and Double Ratchet primitives and is a dependency with its own, separate review status that these re-reviews did not cover.
- A finding is closed only by the independent re-review (the CLOSED rows); D1–D7 were corrected and reviewed without an individual closure verdict.
- N8: envelopes a suspect pre-S1 device queued before the cleanup may still
be delivered afterwards; recipients' session and identity checks still
apply, and a first contact from a cleaned address yields an
UNVERIFIEDpin at most (accepted LOW residual). - N9: tombstones of one-time prekey IDs an attacker published and drained before S1 make the server skip a returning device's uploads of those IDs; new sessions may then run without a one-time prekey (accepted LOW residual, future remediation described above).
- Legacy: v1 initiations not answered before the upgrade are replaced by v2
(their messages resent by the reliability layer); peers and servers must
be upgraded together;
StaticStorageKeyProvidercannot migrate milestone 8 databases. - Registrations from before S1 were never host-authorized and keep their authority, and any offline recovery key they planted, until the operator audits them and runs the offline cleanup (D3, N5). A server whose operator skips the cleanup keeps that exposure.
- The operator is responsible for auditing pre-S1 state and running the cleanup correctly (N6 makes the tested mistakes fail closed; it cannot choose the suspicious registrations).
OutOfMemoryErrorandStackOverflowErrorfrom host code propagate unsanitized (N7); KSecureMessage cannot control what happens after a process-health failure leaves its boundary. Genuine cancellation also propagates unchanged: host code must not attach secrets to cancellation messages or causes, or to those errors.- The pre-S1 cleanup's verification runs after
COMMIT; a failure is reported, not undone (restore the backup). Only the documented non-interactive invocation is tested fail-closed. - The server's wall clock is a security input (freshness windows, challenge expiry, reset delay).
- F7: intent replay and provider-state trust (see F7), accepted for v0.x.
- The correctness of the host's context extractor and authorizer (N1).