Routine device authentication key rotation¶
Milestone 16. Explains how a healthy device replaces its device authentication key (docs/server-authentication.md) while it still holds the current one: the current key K1 authorizes the replacement K2, and K2 proves possession. No other device takes part.
Milestone 17 adds the server-authoritative key installation time and an application-supplied rotation policy on top of that mechanism; see Key age and rotation policy.
Code:
- Rotation statement, signatures, rotation ID:
core/protocol/.../protocol/DeviceAuthenticationRotation.kt(DeviceAuthenticationRotation,DeviceAuthenticationRotationStatement,DeviceAuthenticationRotationAuthorization,DeviceAuthenticationRotationId) - Server contract:
storage/core/.../storage/ServerStorage.kt(DeviceRegistrationRepository.replaceForRotation,RotationReplacement,RotationReplacementResult,DeviceRegistrationState.rotationId) - Server logic:
server/core/.../server/DeviceAuthenticationRotationService.kt(DeviceAuthenticationRotationException,DeviceAuthenticationRotationOutcome),SecureMessageServer.rotateDeviceAuthenticationKey,SecureMessageServer.registrationStatus,ProtectedEndpoint.READ_REGISTRATION - HTTP:
server/ktor/.../KSecureMessageRoutes.kt,client/ktor/.../KtorSecureMessageTransport.rotateDeviceAuthenticationKey/registrationStatus - Key age and policy (M17):
DeviceAuthenticationRegistrationStatus(core:model),DeviceRegistrationState.authKeyInstalledAt,client/core/.../client/DeviceAuthenticationRotationPolicy.kt(DeviceAuthenticationRotationPolicy,DeviceAuthenticationRotationStatus,DeviceAuthenticationRotationDecision,DeviceAuthenticationRotationResult),SecureMessageClient.deviceAuthenticationRotationStatus,evaluateDeviceAuthenticationRotation,rotateDeviceAuthenticationKeyIfNeeded - Client:
SecureMessageClient.prepareDeviceAuthenticationRotation,completeDeviceAuthenticationRotation,rotateDeviceAuthenticationKey,resolveDeviceAuthenticationRotation,cancelDeviceAuthenticationRotation; pending key inDeviceAuthenticationKeyStore
Rotation versus recovery¶
ROUTINE ROTATION (M16) RECOVERY (M14, docs/device-recovery.md)
current key K1 available current key unavailable
K1 authorizes K2 another registered same-user device authorizes
same DeviceAddress, no other device two devices involved
PUT …/registration/rotation PUT …/registration/recovery
KSecureMessage-DeviceAuthRotation-* KSecureMessage-DeviceRecovery-*
The two are separate protocols: different domains, statements, endpoints,
services, client APIs and pending-key slots. A recovery signature never
verifies as a rotation signature and the other way round (domain
separation, tested in DeviceAuthenticationRotationTest). They share only
the server's storage compare-and-set on (key, epoch), so they compose and
race safely (see "Interaction with recovery").
Like recovery, rotation changes exactly one server binding,
DeviceAddress → device authentication public key, and never the messaging
identity, TOFU pins, verification state, safety numbers, sessions, signed or
one-time prekeys, tombstones, mailboxes, pending outbound messages or
processed logical IDs.
Protocol¶
K1 active
↓ prepareDeviceAuthenticationRotation (local, no network)
persist pending K2 (sealed, record type 9)
↓ completeDeviceAuthenticationRotation
GET /v1/devices/{u}/{d}/registration (ServerAuth v1, signed by K1) → {"authEpoch": N, "authKeyInstalledAt": T}
PUT /v1/devices/{u}/{d}/registration/rotation
statement (K1, K2, epoch N, timestamp, nonce)
+ authorization signed by K1 + proof of possession signed by K2
↓
server CAS (K1, epoch N) → (K2, epoch N+1, installed at server now), rotation ID recorded, nonce claimed
↓ 204
client promotes K2 (one transaction), pending cleared
Cryptographic format v1 (frozen)¶
Rotation statement (all integers big-endian):
u32 length | userId (UTF-8)
u32 length | deviceId (UTF-8)
current public key K1 (32, Ed25519)
replacement public key K2 (32, Ed25519)
expected authentication epoch: i64 (>= 1, so identical to a u64)
timestamp: i64, epoch milliseconds (>= 0)
nonce (16)
Each construction prefixes u32 length | domain (UTF-8):
| Construction | Domain | Algorithm |
|---|---|---|
| authorization | KSecureMessage-DeviceAuthRotation-v1 |
Ed25519 by K1 |
| proof of possession | KSecureMessage-DeviceAuthRotation-PoP-v1 |
Ed25519 by K2 |
DeviceAuthenticationRotationId |
KSecureMessage-DeviceAuthRotationId-v1 |
SHA-256, 32 bytes |
The statement binds the device, both keys and the expected epoch, so the
transition (K1, epoch N) → K2 is explicit in what K1 signs, even though
the server knows K1 and N. K1 = K2 cannot be expressed
(DeviceAuthenticationRotationStatement rejects it). The rotation ID is
never an authorization: it identifies the transition for idempotent retries
and as the server's record of which transition installed the current key.
Vectors in core:protocol DeviceAuthenticationRotationTest are frozen and
were computed independently (Python struct, hashlib, cryptography
Ed25519; the same script first reproduced the frozen M14 recovery vectors):
base statement, authorization and PoP signatures, rotation ID, and variants
for another device, current key, replacement key, epoch (also
Long.MAX_VALUE), timestamp, nonce and a UTF-8 address.
No domain is shared with ServerAuth v1, DeviceRecovery v1 or SafetyNumber v1.
Request authentication and nonces¶
The rotation endpoint carries no ServerAuth headers. The K1 authorization over the statement is the request's authentication: it is fresh (timestamp window), single-use (nonce), bound to the device, to the current key and epoch and to the exact replacement. Adding ServerAuth by K1 would add a second nonce claimed in another transaction and would make an exact retry after a lost response fail, because K1 is no longer registered once the server committed.
There is one nonce: the statement nonce, bound into both signatures. It is
claimed under the device's address in the same authentication_nonce table
as ServerAuth nonces (16 random bytes; one namespace per device), inside the
compare-and-set transaction, like M14. A nonce the device already used for
an ordinary request is a replay. Nonces are never cleared by a rotation (or
recovery); they are pruned only by timestamp, as for every request.
The epoch read (GET …/registration) is an ordinary ServerAuth v1 request
with its own nonce, claimed by DeviceAuthenticator as for every protected
endpoint.
Freshness¶
now - 5 min <= timestamp <= now + 5 min, bounds included, millisecond
precision, the server's injected Clock — the same window as ServerAuth and
recovery (DeviceAuthenticationRotation.VALIDITY_WINDOW).
Server semantics¶
DeviceAuthenticationRotationService checks, in this order; an earlier
failure changes nothing:
- the statement names the route's device (the route builds the statement
from the path, so the signatures cover the path's address); sizes, a
positive epoch and K1 ≠ K2 are enforced when the body is decoded
(
400 invalid_device_auth_rotation); - the device is registered (
404 device_auth_rotation_not_registered); - the timestamp is within the window (
401 expired_authentication); - exact retry: if the registered key was installed by the rotation with
this statement's ID and equals K2, both proofs are verified (K1's with the
statement's current key, which the stored ID binds; K2's with the
registered key) and the result is
ALREADY_APPLIED(204), without any write or nonce claim; - the registered key is K1 and the epoch is the expected one
(
409 device_auth_rotation_conflict); - the authorization verifies with the registered key
(
401 invalid_device_auth_rotation_proof); - the proof of possession verifies with K2 (same error);
DeviceRegistrationRepository.replaceForRotation, one transaction: not registered → same rotation ID already installed (ALREADY_APPLIED) → key/epoch differ from the verified state or K2 is the current key (CONFLICT) → epoch isLong.MAX_VALUE(EPOCH_EXHAUSTED,409 device_auth_epoch_exhausted) → prune + claim the nonce (REPLAY,401 authentication_replay) → K2, epoch + 1,rotation_idset,recovery_idcleared,auth_key_installed_at= server time (REPLACED,204).
Unexpected failures are 500 {"error":"internal_error"}; logs name the
address and the failure category only, never keys, signatures or the body.
Authentication epoch¶
Epochs start at 1 on first registration and grow by exactly one per
recovery or rotation. They never wrap: at Long.MAX_VALUE both recovery and
rotation fail closed with EPOCH_EXHAUSTED and nothing changes (SQLite
would otherwise turn auth_epoch + 1 into a REAL). A failed rotation leaves
the epoch unchanged; an idempotent retry does not increment it again.
Transition metadata¶
device_registration keeps the ID of the transition that installed the
current key in one of three columns: recovery_id (a DeviceRecoveryId),
rotation_id (a DeviceAuthenticationRotationId) or, since milestone 18,
last_device_recovery_id (a LastDeviceRecoveryId,
docs/last-device-recovery.md); at most one is set, all are NULL after
first registration. Each transition sets its own column and clears the
other two. The ID kinds never match each other, so an older rotation or
recovery is never mistaken for the one that is applied.
Stale transitions¶
After K1 → K2 and K2 → K3, a replay of the K1 → K2 statement fails: its
current key and epoch are no longer registered (CONFLICT), and with a
statement rewritten against the current state its nonce is still claimed
(REPLAY). K3 stays authoritative, also across server restarts
(SqlDelightServerRotationTest, DeviceAuthenticationRotationRoutesTest).
After a successful rotation¶
From the commit on, only K2 authenticates the device: every ServerAuth
request signed with K1 fails with 401 invalid_authentication, requests
signed with K2 are accepted at once. First registration is unchanged:
registering K2 again is idempotent, registering K1 conflicts.
Interaction with recovery¶
- Rotation then recovery: K1 → K2 by rotation, K2 lost, another device recovers K2 → K3; epochs 1 → 2 → 3.
- Recovery then rotation: K1 → K2 by recovery, then K2 → K3 by rotation.
- Race: a rotation K1 → K2 and a recovery K1 → K3 verified against the same
(K1, epoch N) — exactly one compare-and-set wins, the loser gets its
conflict (
DeviceAuthenticationRotationServerTest, gated deterministically;DeviceAuthenticationRotationRepositoryContractTestwith 32 parties).
On the client, at most one pending key exists: preparing a rotation while a
recovery is pending throws DeviceAuthenticationRecoveryInProgress, and
preparing a recovery while a rotation is pending throws
DeviceAuthenticationRotationInProgress; the storage enforces the same
(storePendingRotationKeyPair / storePendingRecoveryKeyPair refuse).
Milestone 18 adds a third slot, the pending last-device recovery key: while
it is pending, preparing a rotation throws LastDeviceRecoveryInProgress,
rotateDeviceAuthenticationKeyIfNeeded returns RecoveryInProgress and
DeviceAuthenticationRotationStatus.pendingRecovery is true. A last-device
recovery and a rotation from the same (key, epoch) have exactly one
compare-and-set winner (docs/last-device-recovery.md).
Escape path when K1 is lost during an unfinished rotation: call
resolveDeviceAuthenticationRotation() first — if the server already holds
K2 it is promoted and nothing else is needed; otherwise
cancelDeviceAuthenticationRotation() and start a recovery (M14, or M18
with the offline recovery key when no other device exists). The client never
promotes K2 just because K1 disappeared.
Client state¶
- Active key K1: unchanged and used for every request until the server accepted K2.
- Pending rotation key K2: created only by
prepareDeviceAuthenticationRotation(byProtocolEngine, never by storage), persisted before any network I/O, sealed at rest (record type 9), reused across restarts and attempts until completed or cancelled; never generated twice for one rotation. - Completion: reads the epoch with K1, builds a fresh statement (current time, fresh nonce) and sends it; never inside a storage transaction.
- Promotion: pending → active in one transaction (
promotePendingRotationKeyPair), only after the server accepted, answeredALREADY_APPLIED, or the registration probe with K2 succeeded; K1 is overwritten only in that transaction. - Server rejection before mutation (invalid proof, not registered, epoch exhausted, transport failures): K1 stays active, K2 stays pending for another attempt or an explicit cancel; nothing is deleted automatically.
- Cancel: removes only the pending rotation key.
- Nothing rotates implicitly: not in
initialize,send,receive,publishPreKeys, and not by key age. Policy is out of scope.
Lost responses and crashes¶
- Response lost after the server committed: the next
completeDeviceAuthenticationRotationreads the epoch with K1, which the server now rejects (AuthenticationFailed(INVALID)); the client then probes with K2 (resolveDeviceAuthenticationRotation, a registration request signed by K2 — registering the registered key changes nothing) and promotes K2. A rotation rejected asCONFLICT,EXPIREDorREPLAYis resolved the same way before failing. - An exact retry of the same request (same statement bytes) within the
window is answered
ALREADY_APPLIEDby the server. - Crash or failed promotion after the server committed: K2 is still pending
after a restart;
resolveDeviceAuthenticationRotationor anothercompleteDeviceAuthenticationRotationpromotes it. No recovery is needed. - A competing recovery won (for example started from another installation
of this device): K1 is rejected and the K2 probe conflicts; the call fails
with
AuthenticationFailed, K1 and K2 stay as they are. This installation holds neither registered key: cancel the rotation and recover it (docs/device-recovery.md).
Storage¶
- Client:
device_authentication_rotation_key(single row, sealed record type 9, own associated data so it never opens as the active key (type 7) or the recovery key (type 8)); inSEALED_COLUMNS, re-encrypted and covered by the reference scan of storage key rotation (docs/storage-key-rotation.md); a damaged record throwsStorageEncryptionExceptionand is never replaced. Client SQLDelight schema v11 via10.sqm(a new empty table; no existing row changes), frozen fixtureVersion10Schema.InMemoryClientStoragehas the same semantics without encryption. - Server:
device_registration.rotation_id, server schema v3 via server2.sqm(server_storage.format = 3), frozen fixtureServerVersion2Schema. Milestone 17 addsdevice_registration.auth_key_installed_at, server schema v4 via server3.sqm(server_storage.format = 4), frozen fixtureServerVersion3Schema;openrefuses formats 1, 2 and 3 (migrate withSqlDelightServerStorage.Schema).InMemoryServerStoragehas the same semantics. - The client stores no epoch and no installation time (M17 needs no client schema change).
HTTP API¶
GET /v1/devices/{user}/{device}/registration ServerAuth v1 (READ_REGISTRATION)
200 {"authEpoch":7,"authKeyInstalledAt":1767225600000} (M17: installation time, epoch ms)
401 missing_authentication | device_not_registered | expired_authentication
| invalid_authentication | authentication_replay
PUT /v1/devices/{user}/{device}/registration/rotation no auth headers
{"currentPublicKey":"<b64>","replacementPublicKey":"<b64>","authEpoch":7,
"timestamp":1767225600000,"nonce":"<b64>",
"authorizationSignature":"<b64>","proofOfPossession":"<b64>"}
204 rotated or already applied
400 invalid_device_auth_rotation malformed, non-canonical Base64, sizes, epoch < 1, K1 = K2
404 device_auth_rotation_not_registered
401 expired_authentication | invalid_device_auth_rotation_proof | authentication_replay
409 device_auth_rotation_conflict | device_auth_epoch_exhausted
500 internal_error
Signatures cover the binary statement, never the JSON. The recovery route
additionally maps EPOCH_EXHAUSTED to 409 device_auth_epoch_exhausted.
Usage¶
client.rotateDeviceAuthenticationKey() // prepare + complete
// or step by step
client.prepareDeviceAuthenticationRotation() // local only; K2 persisted
client.completeDeviceAuthenticationRotation() // network; promotes K2
// after a crash or lost response
client.resolveDeviceAuthenticationRotation() // true: K2 promoted
client.cancelDeviceAuthenticationRotation() // only if the rotation will not complete
// M17: policy, when the application decides to check
val policy = DeviceAuthenticationRotationPolicy(maxKeyAge = 30.days)
client.deviceAuthenticationRotationStatus() // epoch, installedAt, age, pending flags
client.evaluateDeviceAuthenticationRotation(policy) // NotNeeded / Due / RotationPending / RecoveryPending
client.rotateDeviceAuthenticationKeyIfNeeded(policy) // NotNeeded / Rotated / ResumedPendingRotation / RecoveryInProgress
Tests¶
core:protocolDeviceAuthenticationRotationTest: frozen vectors, tamper, wrong signers, malformed signatures, same key, domain separation.storage:testingDeviceAuthenticationRotationRepositoryContractTest(in-memory, SQLDelight in-memory, file-backed): CAS, epoch, metadata, idempotency, stale replay, exhaustion, 32-party races (identical, different, rotation vs recovery), prekeys/mailbox/nonces untouched.storage:server:sqldelightSqlDelightServerRotationTest(restarts, rollback before/after nonce claim and CAS),SqlDelightServerMigrationTest(v1 → v3, v2 → v3, fresh = migrated),SqlDelightServerStorageOpenTest.server:coreDeviceAuthenticationRotationServerTest,server:ktorDeviceAuthenticationRotationRoutesTest(routes, adapter, client over HTTP, lost response, restarts,internal_error).- M17:
storage:testingcontracts (installation time on registration, retries, recovery, rotation, conflicts, replays),storage:server:sqldelightSqlDelightServerPersistenceTest.keyInstallationTimeSurvivesEveryRestartExactly,SqlDelightServerMigrationTest(v3 → v4, legacy stamping once),SqlDelightServerStorageOpenTest;server:coreDeviceRegistrationStatusServerTest;server:ktorDeviceAuthenticationRotationRoutesTest(exact JSON, restarts);client:coreDeviceAuthenticationRotationPolicyTest(boundary, clamp, infinite, validation) andDeviceAuthenticationRotationPolicyClientTest(end to end, clocks, recovery, pending, lost responses, no implicit calls, concurrency). client:coreDeviceAuthenticationRotationTest;storage:client:sqldelightDeviceAuthenticationRotationStorageTest(sealing, AD separation, corruption, storage key rotation, restarts, crash before promotion, migration fromVersion10Schema);storage:encryptionStorageCipherTest.deviceAuthenticationRotationKeyVectorandClientRecordCipherTest;ClientStorageContractTest.
Key age and rotation policy (milestone 17)¶
M16 is the mechanism. M17 adds what an application needs to decide when to use it:
M16 rotateDeviceAuthenticationKey() the mechanism
M17 status / evaluate / rotate-if-needed is rotation due?
application decides when to call M17 no library scheduler
Authoritative installation time¶
The server records, with every registration, the server time its current
key was installed at: DeviceRegistrationState.authKeyInstalledAt
(device_registration.auth_key_installed_at, epoch milliseconds). It moves
together with the key and the epoch, in the same atomic step, and only
there:
| Event | authEpoch |
authKeyInstalledAt |
|---|---|---|
| first registration | 1 | server now |
| registration retry of the same key (lost response, M14/M16 probe) | unchanged | unchanged |
| device recovery (M14) | + 1 | server now |
| routine rotation (M16) | + 1 | server now |
exact retry (ALREADY_APPLIED), CONFLICT, REPLAY, EXPIRED, rejected registration |
unchanged | unchanged |
"Server now" is the injected server Clock (SecureMessageServer(storage,
clock)), truncated to milliseconds like every auth timestamp. It is never
the client-supplied request, recovery or rotation timestamp, never a local
key creation time and never first use. server:core passes it into the
repository (register(registration, installedAt),
RecoveryReplacement.installedAt, RotationReplacement.installedAt);
repositories read no clock (except the one-time legacy stamping below).
The server owns this metadata because it already owns the key, the epoch and the transitions: the age survives client reinstalls, lost responses, restarts, recovery on another device and client clock changes. The client persists none of it.
Legacy registrations (server schema v3 and older) have no installation
time: 3.sqm adds the column as NULL (SQL has no trusted server clock),
and SqlDelightServerStorage.open(driver, dispatcher, clock) stamps every
NULL row with clock's time in one transaction. That happens once: a
stamped time is never rewritten by a later open, and a row with a time is
never touched (docs/server-storage.md). The legacy key's age therefore
starts at the upgrade, not at its (unknown) historical registration. A
NULL read afterwards fails closed (IllegalStateException, 500).
Registration status¶
The existing signed GET /v1/devices/{user}/{device}/registration (ServerAuth
v1, READ_REGISTRATION, signed by the active key) returns the metadata:
{"authEpoch":7,"authKeyInstalledAt":1767225600000}
authKeyInstalledAt is epoch milliseconds; the Kotlin model is
DeviceAuthenticationRegistrationStatus(authEpoch: Long, authKeyInstalledAt: Instant)
(core:model), returned by SecureMessageServer.registrationStatus and
SecureMessageTransport.registrationStatus (which replaced M16's
authenticationEpoch). No path changed and there is no new endpoint; the
status is never public. ServerAuth authenticates the request, not the
response: the client trusts the server (over HTTPS) as the authority for its
registration metadata, and M17 adds no signed responses. The server never
answers "rotate now": it exposes metadata, not policy.
Policy, status, decision¶
class DeviceAuthenticationRotationPolicy(val maxKeyAge: Duration) // > 0; INFINITE = never due
data class DeviceAuthenticationRotationStatus(
val authEpoch: Long,
val authKeyInstalledAt: Instant, // server time
val evaluatedAt: Instant, // client clock
val pendingRotation: Boolean,
val pendingRecovery: Boolean,
) { val age: Duration } // max(0, evaluatedAt - authKeyInstalledAt)
sealed interface DeviceAuthenticationRotationDecision {
NotNeeded(status), Due(status), RotationPending, RecoveryPending
}
sealed interface DeviceAuthenticationRotationResult {
NotNeeded(status), Rotated(previous), ResumedPendingRotation, RecoveryInProgress
}
- Due exactly when
age >= maxKeyAge(the boundary is due). maxKeyAgemust be positive; zero and negative throwIllegalArgumentException.Duration.INFINITEdisables age-based rotation. There is no default policy and nothing applies one implicitly.- No key material in any of these types.
Client API (SecureMessageClient):
deviceAuthenticationRotationStatus(): reads the status from the server (signed by the active key; never cached) and the local pending flags. ThrowsNotInitializedwithout an active key andAuthenticationFailedif the active key is not registered.evaluateDeviceAuthenticationRotation(policy): a pending recovery givesRecoveryPending, a pending rotationRotationPending, both decided locally without a request; otherwiseDueorNotNeededwith the status. Never rotates, never creates a key.rotateDeviceAuthenticationKeyIfNeeded(policy):- pending recovery →
RecoveryInProgress, nothing started or sent; - pending rotation → completes it with its existing key, including the
M16 lost-response resolution (K1 rejected for the epoch read → K2
registration probe) →
ResumedPendingRotation, whatever the policy says; never a second pending key; - otherwise fetches the status; not due →
NotNeeded(status); due → the M16 rotation (prepare+complete) →Rotated(previous). Errors are the M16 ones; M17 adds no exception type.
None of these is called by initialize, registerDevice, send,
receive, decrypt, publishPreKeys, recovery or anything else in the
library. There is no timer, worker, coroutine loop, WorkManager/BGTask job
or server cron: the application decides when to evaluate.
Clocks¶
The age is the client's clock (SecureMessageClient's injected Clock)
minus the server's installation time:
- Client clock behind the installation time (skew or rollback): the age is clamped to zero; a key never becomes due because a clock moved backwards.
- Client clock jumping forward: the key may become due early. Accepted: routine rotation is safe and explicit.
- This is a wall-clock policy, not a secure monotonic timer: whoever controls the device clock can make a rotation happen earlier or later.
Interaction with recovery and retries¶
- A recovery installs the recovered key at the recovery's server time: a key recovered after its predecessor was 100 days old has age 0.
- A rotation resets the age: an immediate re-evaluation is
NotNeeded. - Registration probes (M14
resolveDeviceAuthenticationRecovery, M16resolveDeviceAuthenticationRotation) and exact retries never refresh the time; a key whose rotation response was lost keeps its commit time.
Concurrency¶
- One instance: the rotation steps (
prepare,complete,resolve,cancel,rotateDeviceAuthenticationKey,…IfNeeded) share a mutex. ConcurrentrotateDeviceAuthenticationKeyIfNeededcalls run one after the other; the later ones see the new key's age and returnNotNeeded. - Several instances on one storage: the storage keeps at most one pending rotation key (and never together with a recovery key), the server's compare-and-set applies at most one rotation per epoch. An instance that finds its accepted key already promoted by another instance treats that as done; otherwise the M16 errors apply.
Health awareness (milestone 25)¶
deviceAuthenticationHealth(policy) combines the local key slots and, only
when they do not decide, one registration status read into one read-only
result (Healthy, RotationDue, RotationPending, RecoveryPending,
ActiveKeyMissing, Unregistered, Inconsistent). It uses this policy's
semantics unchanged (age >= maxKeyAge, clamped age, INFINITE never due,
no default), reports a pending rotation before any age, and never rotates,
resolves or completes anything. See docs/device-authentication-health.md.
Limitations¶
- Rotation is explicit; the M17 policy is evaluated only when the application asks, and the server never triggers it. There is no scheduler.
- The age policy relies on wall-clock time; there is no secure monotonic clock. Only age-based policy: no request-, message- or usage-count policy, and no server-side policy.
- The active key K1 is required; a device that lost it uses recovery (docs/device-recovery.md) or, as the last device, last-device recovery (docs/last-device-recovery.md).
- No account recovery, recovery codes or passwords.
- No messaging identity rotation or recovery; no sealed sender.