From f29618a34e7a7979b1badba96da00bdfc6c3d3ee Mon Sep 17 00:00:00 2001 From: Devin Slick Date: Wed, 30 Sep 2026 20:21:44 -0500 Subject: [PATCH 1/8] Add protocol v2 crypto module (Argon2id + HKDF + AES-GCM envelopes) Implements the fmd-foss/server-protocol v2 key schedule exactly as the fmd-android reference (CryptoV2.kt / LongTermKeys.kt / CypherUtils.java): - derive_password_key: Argon2id(t=1,p=4,m=128MiB) over 'fmd_v2_password'||SHA256(U)||P, HKDF-SHA256 branches for K_auth/K_pmk - decrypt_master_key: AES-GCM unwrap of login's encMasterKey64 under K_pmk - LongTermKeys: per-type KEKs; decrypt_data_blob parses the [iv|dek|tag][iv|data|tag] envelope with context-bound ADs (type||SHA256(U)||item_id||unix_millis_be64) Round-trip tests mirror the Android encrypt path; 5 passing. Spec + OpenAPI v2 saved under docs/ for reference. --- docs/openapi-v2.yaml | 607 ++++++++++++++++++++++++ docs/server-protocol-v2.tex | 821 +++++++++++++++++++++++++++++++++ fmd_api/protocol_v2.py | 197 ++++++++ tests/unit/test_protocol_v2.py | 96 ++++ 4 files changed, 1721 insertions(+) create mode 100644 docs/openapi-v2.yaml create mode 100644 docs/server-protocol-v2.tex create mode 100644 fmd_api/protocol_v2.py create mode 100644 tests/unit/test_protocol_v2.py diff --git a/docs/openapi-v2.yaml b/docs/openapi-v2.yaml new file mode 100644 index 0000000..44d2b72 --- /dev/null +++ b/docs/openapi-v2.yaml @@ -0,0 +1,607 @@ +openapi: "3.0.0" + +# https://swagger.io/docs/specification/v3_0/about/ + +info: + title: FMD Server API v2 + version: "2.0.0" + description: | + This is the REST API for FMD Server, allowing you to remotely locate and control your device. + + In order to understand the format of the encrypted data blobs, please see + the [FMD Server Protocol](https://gitlab.com/fmd-foss/server-protocol), which defines the cryptography used. + APIv2 only supports the FMD Server Protocol v2. + + Per convention, all values suffixed with "64" are Base64-encoded. + contact: + url: https://fmd-foss.org + license: + name: GPL-3.0-or-later + +servers: + - url: /api/v2 + description: This server + + # TODO: This needs a CORS setup to work + # - url: https://server.fmd-foss.org/api/v2 + # description: The production public FMD Server instance + + # - url: https://server-edge.fmd-foss.org/api/v2 + # description: The "cutting edge" public FMD Server instance + +tags: + - name: Account Management + - name: Account Settings + - name: Encrypted Data + description: > + Upload/download all types of data: commands, locations, and pictures. + - name: Server Messages + description: > + Messages generated by the server to notify clients of events. + - name: Other + +paths: + /account/{name}/salt: + get: + tags: [Account Management] + summary: Get the password salt for a user + description: > + Migration behaviour: + Updated clients should get the salt via APIv2 first, since they + don't know a priori which crypto protocol version the account uses. + Then they may fall back to APIv1 for the login if `protoVersion` indicates v1. + operationId: getSalt + parameters: + - name: name + in: path + required: true + schema: + type: string + description: Username + responses: + "200": + description: Salt retrieved successfully + content: + application/json: + schema: + $ref: "#/components/schemas/SaltResponse" + "404": + description: Account not found + content: + text/plain: + schema: + type: string + + /account/login: + post: + tags: [Account Management] + summary: Log in and obtain an access token + operationId: login + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/LoginRequest" + responses: + "200": + description: Login successful + content: + application/json: + schema: + $ref: "#/components/schemas/LoginResponse" + "400": + description: Invalid JSON body or invalid username + content: + text/plain: + schema: + type: string + "403": + description: Access denied (bad credentials) + content: + text/plain: + schema: + type: string + "404": + description: Account not found + content: + text/plain: + schema: + type: string + "423": + description: Account is locked + content: + text/plain: + schema: + type: string + + /account/register: + post: + tags: [Account Management] + summary: Register a new account + operationId: register + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RegisterRequest" + responses: + "200": + description: Registration successful, account auto-logged-in + content: + application/json: + schema: + $ref: "#/components/schemas/RegisterResponse" + "400": + description: Invalid JSON body, username invalid, or username not available + content: + text/plain: + schema: + type: string + "401": + description: Registration token invalid + content: + text/plain: + schema: + type: string + "500": + description: Internal server error, operation failed + content: + text/plain: + schema: + type: string + + /account/logout: + post: + tags: [Account Management] + summary: Revoke the current access token + operationId: logout + security: + - bearerAuth: [] + responses: + "200": + description: Logged out successfully + "400": + description: Missing or malformed Authorization header + "401": + description: Invalid or expired access token + + /account: + delete: + tags: [Account Management] + summary: Delete the authenticated user's account + operationId: deleteAccount + security: + - bearerAuth: [] + responses: + "200": + description: Account deleted + "400": + description: Missing or malformed Authorization header + "401": + description: Invalid or expired access token + "500": + description: Failed to delete account + + # ------- Account Settings ------- + + /account/push_url: + get: + tags: [Account Settings] + summary: Get the account's push URL (for UnifiedPush) + operationId: getPushUrl + security: + - bearerAuth: [] + responses: + "200": + description: Push URL retrieved + content: + application/json: + schema: + $ref: "#/components/schemas/PushUrlRequestResponse" + "400": + description: Missing or malformed Authorization header + "401": + description: Invalid or expired access token + post: + tags: [Account Settings] + summary: Set the account's push URL (for UnifiedPush) + operationId: setPushUrl + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PushUrlRequestResponse" + responses: + "200": + description: Push URL updated + "400": + description: Invalid JSON body, or missing/malformed Authorization header + "401": + description: Invalid or expired access token + + /account/password: + post: + tags: [Account Settings] + summary: Change the account password (and associated salt and encrypted master key) + description: > + Caution: The encrypted master key must be the **same** master key as before, + simply encrypted under a new pre-master key derived from the new password. + If you would change the master key, all data stored on the server would become unreadable! + operationId: changePassword + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PasswordRequest" + responses: + "200": + description: Password updated. All existing sessions were revoked, a fresh access token is returned. + content: + application/json: + schema: + $ref: "#/components/schemas/PasswordResponse" + "400": + description: Invalid JSON body, or missing/malformed Authorization header + "401": + description: Invalid or expired access token + + # ------- Encrypted Data ------- + + /data/{type}: + get: + tags: [Encrypted Data] + summary: Get all items of a given type + operationId: getData + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/DataType" + responses: + "200": + description: Items retrieved + content: + application/json: + schema: + $ref: "#/components/schemas/DataRequestResponse" + "400": + description: Missing or malformed Authorization header + "401": + description: Invalid or expired access token + "500": + description: Internal server error, operation failed + post: + tags: [Encrypted Data] + summary: Add items of a given type + operationId: postData + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/DataType" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DataRequestResponse" + responses: + "200": + description: Items added + "400": + description: Invalid JSON body, or missing/malformed Authorization header + "401": + description: Invalid or expired access token + "500": + description: Internal server error, operation failed + + /data/{type}/all: + delete: + tags: [Encrypted Data] + summary: Delete all items of a given type + operationId: deleteAllData + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/DataType" + responses: + "200": + description: All items of this type deleted + "400": + description: Missing or malformed Authorization header + "401": + description: Invalid or expired access token + "500": + description: Internal server error, operation failed + + /data/{type}/{id}: + delete: + tags: [Encrypted Data] + summary: Delete a single item by its client-assigned id + operationId: deleteSingleDatum + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/DataType" + - name: id + in: path + required: true + description: > + Hex-encoded 128-bit client item id (`clientItemIdHex`) identifying + the item to delete. + schema: + type: string + pattern: "^[0-9a-fA-F]{32}$" + responses: + "200": + description: Item deleted + "400": + description: Missing or malformed Authorization header + "401": + description: Invalid or expired access token + "404": + description: Item not found + "500": + description: Internal server error, operation failed + + # ------- Server Messages ------- + + /messages: + get: + tags: [Server Messages] + summary: Get all server messages for this account + operationId: getMessages + security: + - bearerAuth: [] + responses: + "200": + description: Messages retrieved + content: + application/json: + schema: + $ref: "#/components/schemas/MessagesResponse" + "400": + description: Missing or malformed Authorization header + "401": + description: Invalid or expired access token + "500": + description: Internal server error, operation failed + + /messages/{id}: + delete: + tags: [Server Messages] + summary: Delete a single message + operationId: deleteSingleMessage + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + description: UUID of the message to delete + schema: + type: string + pattern: "^[0-9a-fA-F-]+$" + responses: + "200": + description: Message deleted + "400": + description: Missing or malformed Authorization header + "401": + description: Invalid or expired access token + "404": + description: Message not found + "500": + description: Internal server error, operation failed + + # ------- Other ------- + + /tileServerUrl: + get: + tags: [Other] + summary: Get the configured map tile server URL + operationId: getTileServerUrl + responses: + "200": + description: Tile server URL retrieved + + /version: + get: + tags: [Other] + summary: Get the server version + operationId: getVersion + responses: + "200": + description: Server version retrieved + +# ------- Components ------- + +components: + parameters: + DataType: + name: type + in: path + required: true + description: Type of the encrypted data. + schema: + type: string + enum: [command, location, picture] + + securitySchemes: + bearerAuth: + type: http + scheme: bearer + description: > + Access token issued by /account/login or /account/register. + Clients need to send this as `Authorization: Bearer `. + + schemas: + SaltResponse: + type: object + properties: + salt64: + type: string + protoVersion: + type: integer + format: int32 + description: > + FMD Server Protocol version used by this account. + Clients should fall back to APIv1 if this indicates protocol v1. + + LoginRequest: + type: object + required: + - username + - passwordHash64 + - sessionDurationSeconds + properties: + username: + type: string + passwordHash64: + type: string + sessionDurationSeconds: + type: integer + format: int64 + minimum: 0 + + LoginResponse: + type: object + properties: + accessToken: + type: string + encMasterKey64: + type: string + description: May be empty if the account is on protocol v1. + + RegisterRequest: + type: object + required: + - username + - salt64 + - protoVersion + - passwordHash64 + - encMasterKey64 + - registrationToken + properties: + username: + type: string + salt64: + type: string + passwordHash64: + type: string + protoVersion: + type: integer + format: int32 + minimum: 0 + description: Currently, always "2" for newly registered accounts. + encMasterKey64: + type: string + registrationToken: + type: string + description: Required, but may be empty if the server doesn't enforce a registration token. + + RegisterResponse: + type: object + properties: + accessToken: + type: string + + PushUrlRequestResponse: + type: object + required: + - url + properties: + url: + type: string + + PasswordRequest: + type: object + required: + - newSalt64 + - newPasswordHash64 + - newEncMasterKey64 + properties: + newSalt64: + type: string + newPasswordHash64: + type: string + newEncMasterKey64: + type: string + + PasswordResponse: + type: object + properties: + accessToken: + type: string + + EncryptedItem: + type: object + required: + - clientItemIdHex + - unixMillis + - ciphertext64 + properties: + clientItemIdHex: + type: string + description: Hex-encoded 128-bit id chosen by the client. + pattern: "^[0-9a-fA-F]{32}$" + unixMillis: + type: integer + format: int64 + minimum: 0 + ciphertext64: + type: string + description: Base64-encoded ciphertext of the encrypted command/location/picture/etc. + + DataRequestResponse: + type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/EncryptedItem" + + ServerMessage: + type: object + required: + - uuid + - unixMillis + - code + - text + properties: + uuid: + type: string + description: UUID of this message + pattern: "^[0-9a-fA-F-]+$" + unixMillis: + type: integer + format: int64 + minimum: 0 + code: + type: integer + format: int64 + minimum: 0 + description: > + Code identifying the type of message. + Clients can use this to map the message to a localized string. + text: + type: string + description: > + Optional server-provided text for this message. + Usually, this is empty (to save space), and clients should use the + code to select the string to show to the user. + The "text" field should only have content if the code is "OTHER (1)". + + MessagesResponse: + type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/ServerMessage" diff --git a/docs/server-protocol-v2.tex b/docs/server-protocol-v2.tex new file mode 100644 index 0000000..85ec21d --- /dev/null +++ b/docs/server-protocol-v2.tex @@ -0,0 +1,821 @@ +\documentclass[11pt,a4paper]{article} + +\usepackage[british]{babel} +\usepackage[utf8]{inputenc} + +\usepackage{amsmath} +\usepackage[ + % n, + % advantage, + operators, + sets, + % adversary, + % landau, + probability, + % notions, + % logic, + % ff, + % mm, + % primitives, + % events, + % complexity, + % oracles, + % asymptotics, + % keys +]{cryptocode} +\usepackage{graphicx} +\usepackage{hyperref} +\usepackage{pdflscape} +\usepackage{tabularx} +\usepackage{tikz} + +\createprocedureblock{procb}{center,boxed}{}{}{} +\usetikzlibrary{positioning} + +\newcommand{\FMDRegister}{\mathsf{FMD.Register}} +\newcommand{\FMDLogin}{\mathsf{FMD.Login}} +\newcommand{\FMDUploadData}{\mathsf{FMD.UploadData}} +\newcommand{\FMDDownloadData}{\mathsf{FMD.DownloadData}} + +\newcommand{\AESGCMKeyGen}{\mathsf{AES\text{-}GCM.Keygen}} +\newcommand{\AESGCMEnc}{\mathsf{AES\text{-}GCM.Enc}} +\newcommand{\AESGCMDec}{\mathsf{AES\text{-}GCM.Dec}} +\newcommand{\Argon}{\mathsf{Argon2id}} +\newcommand{\HKDF}{\mathsf{HKDF}} +\newcommand{\SHATwoFiveSix}{\mathsf{SHA2\text{-}256}} +\newcommand{\SHAFiveTwelve}{\mathsf{SHA2\text{-}512}} + +\usepackage{titling} +\setlength{\droptitle}{-5em} % reduce space above title + +\title{The FMD Server Protocol \\[1em] \includegraphics[scale=0.1]{logo.png}} +\author{Thore Göbel} +\date{Version 2.0 -- 2026-09-05} + + +\begin{document} + +\maketitle + +\tableofcontents + + +\newpage + +\section{Overview} + +FMD (``Find My Device'', \href{https://fmd-foss.org/}{fmd-foss.org}) is a libre +and open source system for remotely finding and controlling one's device. +\\ + +Users send \emph{commands} to their devices. +For example, to locate the device by requesting its GPS coordinates (\texttt{fmd locate gps}), +to let it ring (\texttt{fmd ring}), to lock it (\texttt{fmd lock }), +or even to factory-reset it (\texttt{fmd delete }). +Commands are strings and use a CLI-like format. + +Commands are sent over \emph{transport channels} (or \emph{transports} for short) +to the FMD app running on their Android device. +One basic transport channel is SMS. +Users allow-list the phone numbers that may send commands. +\\ + +A more advanced transport channel is \emph{FMD Server}. +Users register their devices with the server, and then use the web interface +to send commands to their device. +The web interface also shows the responses, such as a map with the locations +returned by the device, and pictures taken by the device. + +Fundamentally, FMD Server is just a backend with a database and a REST API. +Notably, it deals with sensitive data (device locations, pictures of the device's surroundings) +and it exposes sensitive functionality (remotely controlling people's devices). +Therefore, data protection and access control are crucial. + +FMD Server is self-hostable, which is the recommended option. +Self-hosted instances may still be shared by multiple users +(for example, a family admin running it for multiple people). +The FMD project provides a hosted instance for users who don't have the means +to self-host FMD Server. \footnote{\url{https://server.fmd-foss.org/}} +In both cases, a natural goal for FMD Server is to prevent a rogue server administrator +from controlling the devices of the users registered on the server. +\\ + +This whitepaper describes the threat model for FMD Server, its security goals, +and the cryptographic protocol designed to achieve these goals. +Together, they form the \emph{FMD Server Protocol}. + + +\newpage + +\section{Threat model} \label{sec:threat-model} + +This section describes the threat model for the FMD Server Protocol. +That is, how much power we grant the adversary. + +\paragraph{Assumption 1: Malicious backend} +We assume that the host that runs the backend code is compromised. +That is, the adversary can read and write the database, +read and write all incoming and outgoing network requests, and change the backend code. + +This is motivated by shared hosting environments: +users should be able to use an FMD Server instance hosted by people they don't trust. + +\paragraph{Assumption 2: Network adversary} +Since the adversary has access to the server, they also have all properties of a Dolev-Yao adversary. +That is, they can read, modify, reorder, replay, and drop messages sent over the network. + +\paragraph{Assumption 3: Secure client} +We assume that the adversary cannot compromise the client code. +That is, the client adheres to the protocol and does not reveal secrets to the adversary. +\\ + +For the Android app, this can be ensured by means of inspecting the source code, +verifying signed build artifacts, and Reproducible Builds. + +For the web client, this is a huge, not currently realistic simplification. +This is because the frontend web client is served by the same server that hosts the backend code. +And that server we assumed above to be compromised! +Unfortunately, this problem is inherent to the web as a platform. +Therefore, we will set this problem aside. +For example, one workaround would be to ship the web client as an Electron-based desktop app. +\\ + +Given FMD's use case of lost or stolen devices, +the adversary may have physical access to the device. +The secure client assumption means that the adversary cannot physically tamper +with the device in a way that compromises the FMD client application. + + +\newpage + +\section{Security goals} + +This section describes the security goals that the FMD Server Protocol (described +in \autoref{sec:protocol}) aims to achieve. + +\paragraph{Goal 1: Confidentiality} +The adversary does not learn sensitive data generated by an honest client, +such as the content of locations and pictures. + +The adversary does not learn the arguments of the commands that are executed. +For example, the adversary does not learn the content of the lockscreen message +in the command \texttt{fmd lock "Please call me at +1 23 456"}. + +\paragraph{Goal 2: Integrity} +All data that an honest client accepts has been previously generated by a honest client of the same account. +The adversary cannot inject new data or swap data between accounts. +This includes locations/pictures/etc. as well as commands. + +\paragraph{Goal 3: No replay} +The adversary cannot replay a command to cause a client to execute a command more than once. + + +\subsection{Non-goals}\label{sec:non-goals} + +The FMD Server Protocol is \emph{not} designed to protect against the scenarios listed below. +These are explicitly out-of-scope. + +\paragraph{Non-goal 1: Command Type Confidentiality} +The adversary is allowed to learn what types of command are executed (locate, picture, ...). +This is necessary for commands that return data. +The server needs to know the data type so that the web client can query a list of locations +(as opposed to a list of pictures). + +\paragraph{Non-goal 2: Availability} +We exclude availability and denial-of-service attacks. +While service unavailability is extremely frustrating when users lose their device and need the service, +a malicious server operator or on-path network adversary can always and trivially block users. +Solving this would require peer-to-peer solutions; yet the design of FMD Server +is intentionally ``centralised'' on a single, self-hostable instance. + +\paragraph{Non-goal 3: Data completeness} +The server is allowed to remove data (locations, pictures, commands). +A malicious server may use this to selectively hide data from a client. +For example, it may only show a subset of locations or it may not deliver a command. + +The motivation for this is to enable the server to autonomously prune data +(for example, to keep only the last 10 pictures), +and to keep the protocol simple. +We leave it to future work to include integrity protection for this in the protocol. + + + + +\newpage + +\section{Protocol}\label{sec:protocol} + +This section describes the FMD Server Protocol. + +\subsection{Notation} + +In the protocol descriptions that follow, the following notations are used: + +\begin{itemize} + \item[$ || $] \quad bitstring concatenation + \item[$ \bin^{l} $] \quad set of bitstrings of length $l$ + \item[$ v \sample S $] \quad value $v$ is sampled uniformly at random from set $S$ + \item[$ v \gets e $] \quad value $v$ is assigned the result of expression $e$ +\end{itemize} + +\subsection{Participants} + +There are two protocol participants: the \textbf{client} $A$ (Android, web, etc.) +and the \textbf{server} $S$ (backend). + +Where necessary, we distinguish between the \emph{device client} +(that is \emph{receiving} commands and is uploading locations/pictures/etc) +and the \emph{web client} (that is \emph{sending} commands and viewing uploaded data). + +\subsection{Fundamental concepts} + +First, we describe the fundamental concepts that are necessary to understand the design of the protocol. + +\paragraph{Protected data} +There are different types of client-to-client data that require protection: + +\begin{itemize} + \item \emph{Commands:} + Uploaded by the web client to the FMD Server backend. + Retrieved by the device client (FMD Android) and executed. + \item \emph{Locations:} + Uploaded by the device client to FMD Server. + Retrieved and displayed by the web client. + \item \emph{Pictures:} Same as locations. + \item \emph{Free-text responses:} + \footnote{This is not yet implemented at the time of writing, but is documented here as a future feature.} + Uploaded by the device client to FMD Server + in response to running a command. + This can be a free-text result string or an error message. + This is the catch-all option for commands other than \texttt{locate} and \texttt{camera}. +\end{itemize} + +In the future, there may be other device clients (desktop, CLI), but the ideas are the same. +There may also be new types of structured data, but the ideas (key hierarchy, encryption algorithm) +from locations/pictures extend to these other data types. + +\paragraph{Unprotected data} +The following data is out-of-scope for the protocol: + +First, server-to-client data that the server generates and sends to the client. +For example, when the server detects multiple failed login attempts +it sends a warning to any logged-in client. +Second, client-to-server data. +For example, the client uploads a push URL, which the server uses to wake up the device +using UnifiedPush\footnote{\url{https://unifiedpush.org/}}. + +This data is \emph{not} protected by the FMD Server Protocol; +it solely relies on the protections provided by TLS. +Only client-to-client data is covered by the protocol. + +\paragraph{Accounts} +Each \emph{account} on FMD Server has a username and passphrase. +The Android device client is used to register a new account. +The web client can be used to log into an account. +Every account has a \emph{username} $U$. +\\ + +There should only be a single device client for every account. +Multi-device accounts are future work (see \autoref{sec:future-work}). +\footnote{ +The Android client can be used to log into an existing account. +It is the responsibility of the user to make sure that they don't log into the +same account on multiple devices. + +If multiple Android devices are logged into the same account, the behaviour is undefined. +For example, the push URL of all but the latest device will be dropped. +Also, the user will not be able to distinguish from which device data is coming from. +} + +\paragraph{Passphrase} +The passphrase $P$ acts as the root of trust. +The protections provided by the security protocol hinge on this passphrase. +\textbf{If the adversary learns the passphrase, the protocol makes no security guarantees.} + +Therefore, the user needs to strike a balance between a passphrase +that is strong enough against a brute-forcing adversary, and a passphrase that they can +recover in the stressful situation of having lost their phone. +For example, they may remember it, look it up in the password manager on their laptop, +or have it on a piece of paper under their pillow. + +The motivation for using a passphrase is to allow for flexibility when the user loses their device. +In such a situation, they need to be able to ask a friend -- or even a random stranger on the street -- for help. +Users need to be able to access their FMD Server account via the web client from any browser. +This would not always be possible with software passkeys or hardware security keys +(for example, your bag was stolen, and the bag contained both your phone and your keyring with the hardware security key). + + +\paragraph{User authentication} +The user authenticates to the FMD Server backend to be allowed to make API requests to access an account. + +Note that malicious read/write access to an account is allowed by the threat model +(because we assumed the server to be malicious). +However, in practice, we clearly want the server to prevent random people +on the internet from modifying account data. +This includes data protected by the client-to-client protocol described in this paper, +but also data that is client-to-server. +For example, only an authenticated user should be able to change the push URL +that FMD Server uses to wake up a device when a command is pending. + + +\subsection{Passphrase hashing}\label{sec:passphrase-hashing} + +The client uses a password-based key derivation function to derive strong symmetric +secrets from the likely human-chosen, low-entropy passphrase. + +\paragraph{Client-side hashing} +Specifically, the client derives an initial \emph{password key} $K_{pwk}$ from the passphrase, +as shown in \autoref{fig:passphrase-hashing}. +It is used as the start of the key schedule (\autoref{sec:key-schedule}). +It never leaves the client. + +As the password-based KDF, $\Argon$ is used with the parameters listed in \autoref{tab:argon-client}. +These parameters were chosen using the considerations of section 4 of +\href{https://datatracker.ietf.org/doc/html/rfc9106}{RFC 9106}. +$m$ was chosen to account for the fact that the clients are mobile devices. +In our testing, these values of $m$ still ran in reasonable time (under two seconds), +whereas higher powers of 2 took significantly longer or caused out-of-memory (OOM) +issues on older/lower-end Android devices. + +\begin{figure}[h] + \centering + \makebox[0cm][]{ + \begin{tikzpicture}[thick] + + \node (P) {``fmd\_v2\_password'' \concat\ $h(U)$ \concat\ passphrase $P$}; + + % hashing password key + \node[draw, dotted] (Argon) [below=of P] {$\Argon$}; + \node (S) [left=of P] {salt $S$}; + \node (PMK) [below=of Argon] {password key $K_{pwk}$}; + \node (Argoninfo) [right=0.3cm of Argon] {}; + + \draw[-] (P) -- (Argon); + \draw[-] (S) -- node {} (Argon); + \draw[->] (Argon) -- node {} (PMK); + + \end{tikzpicture} + } + + \caption{Passphrase hashing (client-side)} + \label{fig:passphrase-hashing} % MUST come after \caption! + \end{figure} + + +\paragraph{Server-side hashing} +From $K_{pwk}$, the client derives a separate \emph{authentication key} $K_{auth}$ with $\HKDF$ +(see the key schedule in \autoref{sec:key-schedule}). +The server receives $K_{auth}$ from the client. +It treats this key as a password-like symmetric authentication token. +The server hashes it again with $\SHAFiveTwelve$ +and compares the resulting hash $h(K_{auth})$ against the hash stored in the database. +If they match, the user is authenticated. + +\begin{table}[] + \centering + \begin{tabular}{|l|l|l|} + \hline + Parameter & Value & Description \\ \hline + $t$ & 1 & iterations\\ + $p$ & 4 & lanes \\ + $m$ & $2^{17}$ KiB = $128$ MiB & memory size \\ + & 256 bit = 32 byte & hash length \\ + & 128 bit = 16 byte & salt length \\ \hline + \end{tabular} + + \caption{Argon2id parameters for client-side hashing} + \label{tab:argon-client} % MUST come after \caption! +\end{table} + + + +\subsection{Key Schedule}\label{sec:key-schedule} + +\autoref{fig:key-schedule} and \autoref{fig:key-schedule-data} show +the key schedule of how keys are derived. +These figures are intended to be read together with the algorithms using these keys, +described in \autoref{sec:subprotocols}. + +The key schedule is split into two halves. +The top half derives an \emph{authentication key} $K_{auth}$ +and a \emph{pre-master key} $K_{pmk}$ from the user's passphrase. +These change whenever the user decides to rotate their passphrase. + +The bottom half derives child keys from a \emph{master key} $K_{master}$. +The master key is generated once during account creation, and afterwards never changes. + +The child keys act as key encryption keys (KEKs). +There is one child key per data type: a location key $K_{loc}$, a picture key $K_{pic}$, +a command key $K_{cmd}$, and so on. + +The individual data items are encrypted using unique, per-item data encryption keys (DEKs). +These are freshly generated for every item, and are encrypted under their respective KEK. +This is shown in \autoref{fig:key-schedule-data}. + +\begin{landscape} + % XXX: This also shifts the page number + \addtolength{\hoffset}{-2.5cm} + \addtolength{\voffset}{+1.75cm} + + \begin{figure} + \centering + \small + + \begin{center} + \begin{tikzpicture}[thick] + % passphrase to pre-master key + \node (P) {``fmd\_v2\_password'' \concat\ $h(U)$ \concat\ passphrase $P$}; + \node (S) [left=of P] {salt $S$}; + \node[draw, dotted] (Argon) [below=of P] {$\Argon$}; + \node (PWK) [below=of Argon] {$K_{pwk}$}; + \node (Argoninfo) [right=0.3cm of Argon] {}; + \draw[-] (P) -- (Argon); + \draw[-] (S) -- node {} (Argon); + \draw[->] (Argon) -- node {} (PWK); + + % authentication key + \node[draw, dotted] (KDFauk) [below left=2cm of PWK] {$\HKDF$}; + \node (KDFaukinfo) [below left=2cm of PWK] [right=0.3cm of KDFauk] {salt=$\emptyset$, info=``fmd\_v2\_auth'' \concat\ $h(U)$}; + \node (AUK) [below=2cm of KDFauk] {$K_{auth}$}; + \draw[-] (PWK) -- (KDFauk); + \draw[->] (KDFauk) -- (AUK); + + % pre-master key + \node[draw, dotted] (KDFpmk) [right=2cm of KDFaukinfo] {$\HKDF$}; + \node (KDFpmkinfo) [right=0.3cm of KDFpmk] {salt=$\emptyset$, info=``fmd\_v2\_premaster'' \concat\ $h(U)$}; + \node (PMK) [below=2cm of KDFpmk] {$K_{pmk}$}; + \draw[-] (PWK) -- (KDFpmk); + \draw[->] (KDFpmk) -- (PMK); + + % decrypting the master key + \node (IVmk) [right=of PMK] {$IV_{master}$}; + \node (EMK) [right=of IVmk] {$C_{master}$}; + \node[draw, dotted] (EMKdec) [below=of IVmk] {$\AESGCMDec$}; + \node (EMKad) [right=0.3cm of EMKdec] {$AD=$ ``fmd\_v2\_master'' \concat\ $h(U)$}; + \node (MK) [below=of EMKdec] {$K_{master}$}; + \draw[-] (IVmk) -- (EMKdec); + \draw[-] (EMK) -- (EMKdec); + \draw[-] (PMK) -- (EMKdec); + \draw[->] (EMKdec) -- (MK); + + % picture keys + \node[draw, dotted] (KDFpic) [below=2cm of MK] {$\HKDF$}; + \node (KDFpicinfo) [right=0.3cm of KDFpic] {salt=$\emptyset$, info=``fmd\_v2\_kek\_picture'' \concat\ $h(U)$}; + \node (PICK) [below=of KDFpic] {$K_{pic}$}; + \draw[-] (MK) -- (KDFpic); + \draw[->] (KDFpic) -- (PICK); + + % location keys + \node (KDFlocinfo) [left=of KDFpic] {salt=$\emptyset$, info=``fmd\_v2\_kek\_location'' \concat\ $h(U)$}; + \node[draw, dotted] (KDFloc) [left=of KDFlocinfo] {$\HKDF$}; + \node (Kloc) [below=of KDFloc] {$K_{loc}$}; + \draw[-] (MK) -- (KDFloc); + \draw[->] (KDFloc) -- (Kloc); + + % command keys + \node (KDFcmds) [right=of KDFpicinfo] {$\dots$}; + \draw[-] (MK) -- (KDFcmds); + + \end{tikzpicture} + \end{center} + + \caption{Key Schedule (part 1)} + \label{fig:key-schedule} % MUST come after \caption! +\end{figure} + + + +\begin{figure} + \centering + + \begin{center} + \begin{tikzpicture}[thick] + + % location keys + \node[draw, dotted] (AESKeyGen) {$\AESGCMKeyGen$}; + \node (Kloci) [below=of AESKeyGen] {$K_{k_{id}}$}; + \draw[->] (AESKeyGen) -- (Kloci); + + % encrypt data key + \node[draw, dotted] (AESEncKey) [below left=4cm of Kloci] {$\AESGCMEnc$}; + \node (IVkey) [above=of AESEncKey] {$IV_{k_{id}}$}; + \node (Kloc) [left=0.5cm of IVkey] {$K_{loc}$}; + \node (ADkey) [right=0.2cm of AESEncKey] {$AD = $ ``fmd\_v2\_dek\_location'' \concat\ $h(U)$ \concat\ $id$ \concat\ $t$}; + \node (EncKey) [below=of AESEncKey] {encrypted location data key $C_{k_{id}}$}; + \draw[-] (Kloc) -- (AESEncKey); + \draw[-] (IVkey) -- (AESEncKey); + \draw[-] (Kloci) -- (AESEncKey); + \draw[->] (AESEncKey) -- (EncKey); + + % encrypt location data + \node[draw, dotted] (AESEncData) [right=0.1cm of ADkey] {$\AESGCMEnc$}; + \node (IVdata) [above=of AESEncData] {$IV_{d_{id}}$}; + \node (LOCI) [above right=of AESEncData] {$loc_{id}$}; + \node (ADdata) [right=0.1cm of AESEncData] {$AD = $ ``fmd\_v2\_data\_location'' \concat\ $h(U)$ \concat\ $id$ \concat\ $t$}; + \node (EncData) [below=of AESEncData] {encrypted location $C_{d_{id}}$}; + \draw[-] (Kloci) -- (AESEncData); + \draw[-] (IVdata) -- (AESEncData); + \draw[-] (LOCI) -- (AESEncData); + \draw[->] (AESEncData) -- (EncData); + + \end{tikzpicture} + \end{center} + + \caption{Key Schedule (part 2)} + \label{fig:key-schedule-data} % MUST come after \caption! +\end{figure} + + +\end{landscape} + + + + +\subsection{Subprotocols}\label{sec:subprotocols} + +This section describes the subprotocols of the FMD Server Protocol: +$\FMDRegister$, $\FMDLogin$, $\FMDUploadData$, and $\FMDDownloadData$. + +\paragraph{Principles} +The design is guided by the following principles: + +\begin{itemize} + \item Keep it simple. + \item Use modern algorithms. + \item Hardcode algorithms and their parameters. + If we need to switch algorithms, we will release a new protocol version. + \item Use context binding as much as possible to avoid confusions (in KDFs and AEAD Associated Data). +\end{itemize} + +\paragraph{Encoding} +Strings are \textbf{UTF-8} encoded. +Integers are \textbf{big endian} encoded. + +Note: For simplicity, the encoding step is usually not shown. + +\paragraph{HKDF} +All HKDF calls use $\SHATwoFiveSix$ as the hash function. + +\paragraph{Context strings} +Context strings use the following format: + +$$ \text{``fmd\_v2\_''} \concat\ \kappa\ \concat\ \text{``\_''}\ \concat\ \tau\ \concat\ h(U)\ \concat\ id\ \concat\ t $$ + +$\kappa$ is the type (\emph{kek}, \emph{dek}, \emph{data}) of content being derived or encrypted. +$\tau$ is the type of user data. +The username $U$ is hashed. $h$ is fixed to $\SHATwoFiveSix$. +This is to normalise the username string to avoid attacks with maliciously chosen usernames. +The $id$ must be a 128-bit bitstring generated freshly for every data item and uniformly at random. +$t$ is a Unix milliseconds timestamp as a 64-bit integer. + +\paragraph{Types of data} +For simplicity, the subprotocols are specified using $\tau$ as the parameter for the data type. +At the time of writing, these are ``location'', ``picture'', ``text'', and ``command''. +Where special handling is needed, this is noted. + + +\subsubsection{Registration} + +The $\FMDRegister$ subprotocol (\autoref{alg:register}) is used to register a new account for user $U$ with the server. + +The client derives a pre-master key $K_{pmk}$ from the user's passphrase $P$. +The client locally generates a master key $K_{master}$, symmetrically encrypts it under $K_{pmk}$, +and uploads the result to the server. +This allows passphrase rotation: when the user changes their passphrase, +only the $K_{master}$ needs to be re-encrypted under the new $K_{pmk}'$ and re-uploaded. +This avoids re-encrypting all stored data. + + +\subsubsection{Login} + +The $\FMDLogin$ subprotocol (\autoref{alg:login}) is used by the client to log in, create a new session, +and obtain the $K_{master}$. + +For the session, the server returns an $accessToken$ used to authorize subsequent requests +that access that user's data. +Since this is basic client-to-server access control, +we omit the $accessToken$ in the other subprotocol descriptions. +In practice, the client includes the $accessToken$ as an HTTP header field in subsequent requests. + + +\subsubsection{Uploading data} + +The $\FMDUploadData$ subprotocol (\autoref{alg:upload-data}) is used by the client to upload new data to the server. + +Every datum is encrypted under a freshly generated \emph{data encryption key (DEK)}. +The DEK is encrypted under a long-term \emph{key encryption key (KEK)}. +Every data type has its own KEK. + +The plaintext of every data type internally uses its own structure. +For example, the location may have fields for latitude, longitude, altitude, and so on. +This structure is not relevant for the protocol, we generalise this as a data blob $D$. + + +\subsubsection{Downloading data} + +The $\FMDDownloadData$ subprotocol (\autoref{alg:download-data}) is used by the client to download data from the server. + +We assume that the client knows which ids $id_i$ exist for any given data type $\tau$. +In practice, the server provides an unauthenticated list of ids. +This is in line with the non-goals described in \autoref{sec:non-goals}. + +Once decrypted, the client parses the data value $D$. +For most types (location, picture, text), it simply displays the data to the user. +For commands, there is additional handling: + +\paragraph{Command replay protection} +To prevent replay attacks, the client stores the time $t'$. +When a new command is downloaded, the client compares the new time $t$ against the stored $t'$. +If and only if $t > t'$, the client executes the command, otherwise it rejects it. +Because honest clients choose $t$ as a Unix millisecond timestamp, $t$ should be strictly increasing. + +If the client loses its storage (e.g., due to a reinstall), $t'$ is reset, +making replay attacks possible. +This risk is accepted, as device clients are assumed to have long-term storage. + +\begin{landscape} + +\begin{figure} + \procb{$\FMDRegister$}{ + \textbf{Client} \> \> \textbf{Server} \\ + \pcln \text{Knows: } U, P \> \> \\ + \\ + \pcln S \sample \bin ^s \pccomment{salt} \> \> \\ + % \pccomment{Compute hashes} \\ + \pcln K_{pwk} \gets \Argon (\text{``fmd\_v2\_password''} \concat\ h(U)\ \concat\ P, \; S) \> \> \\ + \\ + \pcln K_{auth} \gets \HKDF (K_{pwk}, \; salt=\emptyset, \; info = \text{``fmd\_v2\_auth''} \concat\ h(U) ) \> \> \\ + \pcln K_{pmk} \gets \HKDF (K_{pwk}, \; salt=\emptyset, \; info = \text{``fmd\_v2\_premaster''} \concat\ h(U) ) \> \> \\ + \\ + \pcln IV_{master} \sample \bin^{96} \> \> \\ + \pcln K_{master} \sample \bin^{256} \> \> \\ + \pcln AD \gets \text{``fmd\_v2\_master''} \concat\ h(U) \> \> \\ + \pcln C_{master} \gets \AESGCMEnc_{K_{pmk}} (IV_{master}, K_{master}, AD) \> \> \\ + % + \pcln \> \sendmessageright*{U, S, IV_{master}, C_{master}, K_{auth}} \> \\ + \pcln \> \> \pcif U \text{ exists}: \pcabort \\ + \pcln \> \> hK_{auth} \gets \SHAFiveTwelve (K_{auth}) \\ + \pcln \text{Store: } K_{master} \> \> \text{Store: } U, S, IV_{master}, C_{master}, hK_{auth} + % TODO: server-side hashing + } + \caption{$\FMDRegister$ algorithm} + \label{alg:register} % MUST come after \caption! +\end{figure} + +\begin{figure} + \procb{$\FMDLogin$}{ + \textbf{Client} \> \> \textbf{Server} \\ + \pcln \text{Knows: } U, P \> \> \text{Knows: } U, S, IV_{master}, C_{master}, hK_{auth} \\ + \\ + \pcln \> \sendmessageright*{U} \> \\ + \pcln \> \sendmessageleft*{S} \> \\ + \pcln K_{pwk} \gets \Argon (\text{``fmd\_v2\_password''} \concat\ h(U)\ \concat\ P, \; S) \> \> \\ + \pcln K_{auth} \gets \HKDF (K_{pwk}, \; salt=\emptyset, \; info = \text{``fmd\_v2\_auth''} \concat\ h(U) ) \> \> \\ + \pcln \> \sendmessageright*{K_{auth}} \> \\ + \> \> hK_{auth}' \gets \SHAFiveTwelve (K_{auth}) \\ + \> \> \pcif hK_{auth}' \neq hK_{auth}: \pcabort \\ + \\ + \> \> accessToken \sample \bin^* \\ + \pcln \> \sendmessageleft*{accessToken} \> \pccomment{authenticates subsequent requests} \\ + \pcln \> \sendmessageleft*{IV_{master}, C_{master}} \> \\ + \pcln K_{pmk} \gets \HKDF (K_{pwk}, \; salt=\emptyset, \; info = \text{``fmd\_v2\_premaster''} \concat\ h(U) ) \> \> \\ + \pcln AD \gets \text{``fmd\_v2\_master''} \concat\ h(U) \> \> \\ + \pcln K_{master} \gets \AESGCMDec_{K_{pmk}} (IV_{master}, C_{master}, AD) \> \> \\ + \\ + \pcln \text{Knows: } K_{master}, accessToken \> \> + } + \caption{$\FMDLogin$ algorithm} + \label{alg:login} % MUST come after \caption! +\end{figure} + +\begin{figure} + \procb{$\FMDUploadData$}{ + \textbf{Client} \> \> \textbf{Server} \\ + \pcln \text{Knows: } U, K_{master} \> \> \text{Knows: } U \\ + \\ + \pcln D \gets \text{data value (location, picture, text, command)} \> \> \\ + \pcln \tau \gets \text{data type} \> \> \\ + \pcln id \sample \bin^{128} \> \> \\ + \pcln t \gets \text{current Unix milliseconds} \> \> \\ + \\ + \pcln K_{\tau} \gets \HKDF (K_{master}, \; salt=\emptyset, \; info = \text{``fmd\_v2\_kek\_''} \concat\ \tau\ \concat\ h(U) \; ) \> \> \\ + \pcln K_{k_{id}} \sample \bin^{256} \> \> \\ + \\ + \pcln IV_{k_{id}} \sample \bin^{96} \> \> \\ + \pcln AD_{k_{id}} \gets \text{``fmd\_v2\_dek\_''} \concat\ \tau\ \concat\ h(U)\ \concat\ id\ \concat\ t \> \> \\ + \pcln C_{k_{id}} \gets \AESGCMEnc_{K_{\tau}} (IV_{k_{id}}, K_{k_{id}}, AD_{k_{id}}) \qquad \pccomment{key encryption} \> \> \\ + \\ + \pcln IV_{d_{id}} \sample \bin^{96} \> \> \\ + \pcln AD_{d_{id}} \gets \text{``fmd\_v2\_data\_''} \concat\ \tau\ \concat\ h(U)\ \concat\ id\ \concat\ t \> \> \\ + \pcln C_{d_{id}} \gets \AESGCMEnc_{K_{k_{id}}} (IV_{d_{id}}, D, AD_{d_{id}}) \qquad \pccomment{data encryption} \> \> \\ + \\ + \pcln \> \sendmessageright*{ U, \tau, id, t, IV_{k_{id}}, C_{k_{id}}, IV_{d_{id}}, C_{d_{id}} } \> \\ + \pcln \> \> \text{Stores: } \langle U, \tau, id, t, IV_{k_{id}}, C_{k_{id}}, IV_{d_{id}}, C_{d_{id}} \rangle + % \pcln \> \> \\ + } + \caption{$\FMDUploadData$ algorithm} + \label{alg:upload-data} % MUST come after \caption! +\end{figure} + +\begin{figure} + \procb{$\FMDDownloadData$}{ + \textbf{Client} \> \> \textbf{Server} \\ + \pcln \text{Knows: } U, K_{master}, t' \> \> \text{Knows: } \{ \langle U, \tau, id, t, IV_{k_{id}}, C_{k_{id}}, IV_{d_{id}}, C_{d_{id}} \rangle _i \}_i \\ + \\ + \pcln \tau \gets \text{data type} \> \> \\ + \pcln K_{\tau} \gets \HKDF (K_{master}, \; salt=\emptyset, \; info = \text{``fmd\_v2\_kek\_''} \concat\ \tau\ \concat\ h(U) \; ) \> \> \\ + \pcln \> \sendmessageright*{U, \tau} \> \\ + % + \pcln \> \> \{id_i\}_i \gets \text{select all $id$ matching $U$ and $\tau$}\\ + \pcln \> \sendmessageleft*{ \{id_i\}_i } \> \pccomment{server provides unauthenticated list of ids} \\ + \pcln id \gets \text{select from } \{id_i\}_i \> \> \\ + \pcln \> \sendmessageright*{U, \tau, id} \> \\ + % + \pcln \> \sendmessageleft*{ t, IV_{k_{id}}, C_{k_{id}}, IV_{d_{id}}, C_{d_{id}} } \> \\ + \\ + \pcln AD_{k_{id}} \gets \text{``fmd\_v2\_dek\_''} \concat\ \tau\ \concat\ h(U)\ \concat\ id\ \concat\ t \> \> \\ + \pcln K_{k_{id}} \gets \AESGCMDec_{K_{\tau}} (IV_{k_{id}}, C_{k_{id}}, AD_{k_{id}}) \qquad \pccomment{key decryption} \> \> \\ + \\ + \pcln AD_{d_{id}} \gets \text{``fmd\_v2\_data\_''} \concat\ \tau\ \concat\ h(U)\ \concat\ id\ \concat\ t \> \> \\ + \pcln D \gets \AESGCMDec_{K_{k_{id}}} (IV_{d_{id}}, C_{d_{id}}, AD_{d_{id}}) \qquad \pccomment{data decryption} \> \> \\ + \\ + \pcln \pcif \tau = \text{``cmd''}: \> \> \\ + \pcln \t \pcif t \leq t': \pcabort \> \> \\ + \pcln \t \pcelse : t' \gets t \> \> \\ + \pcln \text{Parse and handle } D \> \> + % \pcln \> \> \\ + } + \caption{$\FMDDownloadData$ algorithm} + \label{alg:download-data} % MUST come after \caption! +\end{figure} + +\end{landscape} + + + +\newpage + +\section{Future work}\label{sec:future-work} + +Below, we list ideas for future extensions of FMD Server. +Especially the last two introduce significant complexity to the specification and an implementation, +and are therefore out-of-scope of this protocol version. + +\paragraph{Authentication with an aPAKE} +Instead of deriving a separate authentication key $K_{auth}$ to send to the server as +the authentication value (see \autoref{sec:passphrase-hashing}), +we might instead use an \emph{asymmetric/augmented password-authenticated key exchange (aPAKE)} +for authenticating users. +For example, 1Password\footnote{\url{https://agilebits.github.io/security-design/srp.html}} +and Proton\footnote{\url{https://proton.me/blog/encrypted-email-authentication}} use SRP, +and WhatsApp\footnote{\url{https://www.nccgroup.com/research-blog/public-report-whatsapp-opaque-ke-cryptographic-implementation-review/}} +uses OPAQUE. + +\paragraph{Multi-device} +The current protocol is designed with ``one device = one account'' in mind. +A user who has multiple devices (phone, table, laptop) thus needs to register one account for each device. +With multi-device, the user would register a single account to which they can connect multiple devices. + +\paragraph{Sharing data between accounts} +Services comparable to FMD -- such as Apple Find My or Google Find Hub -- +allow users to share their location with friends and family. +This feature needs asymmetric cryptography (with every account having an identity key pair) +and logic to securely share data selectively between accounts. + +At this point, with these sharing capabilities, FMD Server would become +an end-to-end-encrypted cloud storage (even more than it already is). +Encrypted cloud storage is an active area of research\footnote{\url{https://eprint.iacr.org/2024/989}}, +with existing schemes often being broken.\footnote{\url{https://eprint.iacr.org/2022/959}, \url{https://eprint.iacr.org/2024/1616}} +Therefore, any such extension requires careful design and formal analysis before deployment. + + +% \newpage + +% \section{Conclusion} + +% TODO + + + +\newpage + +\appendix + +\begin{landscape} + + \section{Version history} + + \begin{tabularx}{\linewidth}{l|l|X|l|l} + Protocol version & Date published & Description & FMD Server version & REST API version \\ + \hline + \hline + 0.x & $\approx$ 2021 & Original protocol. Never formally specified. Broken in active adversary model. & 0.x & v1 \\ + \hline + 1.0 & -- & Skipped to align with REST API. & -- & -- \\ + \hline + 2.0 & 2026-09-05 & Initial stable, non-0.x version. & 0.17.0, 1.x & v2 \\ + \end{tabularx} + +\end{landscape} + +\end{document} diff --git a/fmd_api/protocol_v2.py b/fmd_api/protocol_v2.py new file mode 100644 index 0000000..c01ad08 --- /dev/null +++ b/fmd_api/protocol_v2.py @@ -0,0 +1,197 @@ +"""FMD Server Protocol v2 cryptography. + +Implements the key schedule and data-envelope format specified in +https://gitlab.com/fmd-foss/server-protocol (main.tex), mirroring the +reference implementation in fmd-android (CryptoV2.kt, LongTermKeys.kt, +CypherUtils.java). + +All AES-GCM calls use 256-bit keys, 96-bit IVs and 128-bit tags. +Wire format of an encrypted data item's ciphertext:: + + encrypted_dek = iv(12) || ct(key=32) || tag(16) # AES-GCM under K_tau + encrypted_data = iv(12) || ct(data) || tag(16) # AES-GCM under DEK + ciphertext64 = base64( encrypted_dek || encrypted_data ) + +Associated-data context strings bind every operation to the username, +data type, per-item 128-bit id and the Unix-millisecond timestamp, +exactly as in the protocol spec ("Context strings" section). +""" + +from __future__ import annotations + +import base64 +import hashlib +import struct +from typing import Final + +from argon2.low_level import Type, hash_secret_raw +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +# --- Context strings (protocol v2) ----------------------------------------- +CTX_PASSWORD: Final = b"fmd_v2_password" +CTX_AUTH: Final = b"fmd_v2_auth" +CTX_PREMASTER: Final = b"fmd_v2_premaster" +CTX_MASTER: Final = b"fmd_v2_master" + +# KEK contexts, one per data type +CTX_KEK_COMMAND: Final = b"fmd_v2_kek_command" +CTX_KEK_LOCATION: Final = b"fmd_v2_kek_location" +CTX_KEK_PICTURE: Final = b"fmd_v2_kek_picture" + +# Data envelope contexts; suffixed with the data-type label +CTX_DEK: Final = b"fmd_v2_dek_" +CTX_DATA: Final = b"fmd_v2_data_" + +# --- Sizes (bytes) ---------------------------------------------------------- +AES_GCM_IV_SIZE: Final = 12 +AES_GCM_KEY_SIZE: Final = 32 +AES_GCM_TAG_SIZE: Final = 16 +CLIENT_ITEM_ID_SIZE: Final = 16 # 128-bit id chosen by the client + +# Argon2id parameters (protocol spec table "Argon2id parameters for +# client-side hashing") +ARGON2_TIME_COST: Final = 1 +ARGON2_PARALLELISM: Final = 4 +ARGON2_MEMORY_KIB: Final = 131072 # 2**17 KiB = 128 MiB +ARGON2_HASH_LENGTH: Final = 32 + +# Data-type labels +TYPE_LOCATION: Final = "location" +TYPE_PICTURE: Final = "picture" +TYPE_COMMAND: Final = "command" + +_KEK_CONTEXTS: Final[dict[str, bytes]] = { + TYPE_LOCATION: CTX_KEK_LOCATION, + TYPE_PICTURE: CTX_KEK_PICTURE, + TYPE_COMMAND: CTX_KEK_COMMAND, +} + + +class ProtocolError(Exception): + """Raised when a protocol-v2 operation fails (bad ciphertext, AD).""" + + +def _hash_username(username: str) -> bytes: + """SHA-256 of the UTF-8 username, as required for context binding.""" + return hashlib.sha256(username.encode("utf-8")).digest() + + +def hkdf_derive(ikm: bytes, info: bytes, length: int = 32) -> bytes: + """HKDF-SHA256 extract-and-expand (salt is empty per the spec).""" + # Expand-only would also be spec-conformant (salt empty => PRK = HMAC + # of ikm), but full extract+expand matches the reference implementation + # (BouncyCastle HKDFBytesGenerator with HKDFParameters(ikm, null, info)). + import cryptography.hazmat.primitives.hashes as _hashes + from cryptography.hazmat.primitives.kdf.hkdf import HKDF + + return HKDF( + algorithm=_hashes.SHA256(), + length=length, + salt=None, + info=info, + ).derive(ikm) + + +def derive_password_key( + username: str, password: str, salt: bytes +) -> tuple[bytes, bytes]: + """Derive (K_auth, K_pmk) from the passphrase. + + K_pwk = Argon2id("fmd_v2_password" || SHA256(U) || P, salt) + K_auth = HKDF(K_pwk, info="fmd_v2_auth" || SHA256(U)) + K_pmk = HKDF(K_pwk, info="fmd_v2_premaster" || SHA256(U)) + """ + pwk = hash_secret_raw( + secret=CTX_PASSWORD + _hash_username(username) + password.encode("utf-8"), + salt=salt, + time_cost=ARGON2_TIME_COST, + parallelism=ARGON2_PARALLELISM, + memory_cost=ARGON2_MEMORY_KIB, + hash_len=ARGON2_HASH_LENGTH, + type=Type.ID, + ) + user_hash = _hash_username(username) + k_auth = hkdf_derive(pwk, CTX_AUTH + user_hash) + k_pmk = hkdf_derive(pwk, CTX_PREMASTER + user_hash) + return k_auth, k_pmk + + +def decrypt_master_key( + username: str, pre_master_key: bytes, encrypted_master_key: bytes +) -> bytes: + """Decrypt the account master key (login's encMasterKey64 payload). + + The server returns ``encMasterKey64`` = base64( iv || ct || tag ) + encrypted under K_pmk with AD = "fmd_v2_master" || SHA256(U). + """ + blob = encrypted_master_key + ad = CTX_MASTER + _hash_username(username) + try: + return AESGCM(pre_master_key).decrypt(blob[:AES_GCM_IV_SIZE], blob[AES_GCM_IV_SIZE:], ad) + except Exception as exc: # noqa: BLE001 + msg = "Failed to decrypt account master key" + raise ProtocolError(msg) from exc + + +def derive_kek(master_key: bytes, username: str, data_type: str) -> bytes: + """Derive the per-type key-encryption key K_tau.""" + context = _KEK_CONTEXTS.get(data_type) + if context is None: + msg = f"Unknown data type: {data_type!r}" + raise ProtocolError(msg) + return hkdf_derive(master_key, context + _hash_username(username)) + + +class LongTermKeys: + """Per-type KEKs derived from the account master key.""" + + def __init__(self, username: str, master_key: bytes) -> None: + """Initialize keys for a user.""" + self.username = username + self.master_key = master_key + self.location_key = derive_kek(master_key, username, TYPE_LOCATION) + self.picture_key = derive_kek(master_key, username, TYPE_PICTURE) + self.command_key = derive_kek(master_key, username, TYPE_COMMAND) + + def _kek(self, data_type: str) -> bytes: + return { + TYPE_LOCATION: self.location_key, + TYPE_PICTURE: self.picture_key, + TYPE_COMMAND: self.command_key, + }[data_type] + + def decrypt_data_blob( + self, item_id: bytes, unix_millis: int, data_type: str, ciphertext: bytes + ) -> bytes: + """Decrypt one EncryptedItem ciphertext (see module docstring). + + Does NOT validate the timestamp or item id (mirrors the Android + reference behaviour); callers apply command freshness rules. + """ + dek_end = AES_GCM_IV_SIZE + AES_GCM_KEY_SIZE + AES_GCM_TAG_SIZE + if len(ciphertext) <= dek_end: + msg = "Ciphertext too short for protocol v2 envelope" + raise ProtocolError(msg) + encrypted_dek = ciphertext[:dek_end] + encrypted_data = ciphertext[dek_end:] + + ad_suffix = ( + data_type.encode("utf-8") + + _hash_username(self.username) + + item_id + + struct.pack(">q", unix_millis) + ) + try: + dek = AESGCM(self._kek(data_type)).decrypt( + encrypted_dek[:AES_GCM_IV_SIZE], + encrypted_dek[AES_GCM_IV_SIZE:], + CTX_DEK + ad_suffix, + ) + return AESGCM(dek).decrypt( + encrypted_data[:AES_GCM_IV_SIZE], + encrypted_data[AES_GCM_IV_SIZE:], + CTX_DATA + ad_suffix, + ) + except Exception as exc: # noqa: BLE001 + msg = "Failed to decrypt protocol v2 data blob" + raise ProtocolError(msg) from exc diff --git a/tests/unit/test_protocol_v2.py b/tests/unit/test_protocol_v2.py new file mode 100644 index 0000000..f14296a --- /dev/null +++ b/tests/unit/test_protocol_v2.py @@ -0,0 +1,96 @@ +"""Round-trip tests for fmd_api.protocol_v2 against the fmd-android wire format.""" + +from __future__ import annotations + +import base64 +import os +import struct + +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +from fmd_api.protocol_v2 import ( + CTX_DATA, + CTX_DEK, + AES_GCM_IV_SIZE, + LongTermKeys, + derive_password_key, + decrypt_master_key, +) + +USERNAME = "test-user" +PASSWORD = "correct horse battery staple" +SALT = os.urandom(16) +MASTER_KEY = os.urandom(32) + + + +def test_password_key_derivation_deterministic() -> None: + k1 = derive_password_key(USERNAME, PASSWORD, SALT) + k2 = derive_password_key(USERNAME, PASSWORD, SALT) + assert k1 == k2 + assert k1[0] != k1[1] # K_auth != K_pmk + assert len(k1[0]) == 32 and len(k1[1]) == 32 + + +def test_password_key_different_salt() -> None: + k1 = derive_password_key(USERNAME, PASSWORD, SALT) + k2 = derive_password_key(USERNAME, PASSWORD, os.urandom(16)) + assert k1 != k2 + + +def test_master_key_roundtrip() -> None: + import hashlib + + ad = b"fmd_v2_master" + hashlib.sha256(USERNAME.encode()).digest() + _, k_pmk = derive_password_key(USERNAME, PASSWORD, SALT) + iv = os.urandom(12) + enc = iv + AESGCM(k_pmk).encrypt(iv, MASTER_KEY, ad) + assert decrypt_master_key(USERNAME, k_pmk, enc) == MASTER_KEY + + +def test_data_blob_roundtrip_against_android_format() -> None: + ltk = LongTermKeys(USERNAME, MASTER_KEY) + plaintext = b'{"lat": 32.83, "lon": -96.96, "provider": "gps"}' + + # reference encrypt (android format) + import hashlib + + item_id = os.urandom(16) + unix_millis = 1790600000000 + user_hash = hashlib.sha256(USERNAME.encode()).digest() + ad_suffix = ( + b"location" + user_hash + item_id + struct.pack(">q", unix_millis) + ) + dek = os.urandom(32) + iv1 = os.urandom(12) + iv2 = os.urandom(12) + enc_dek = iv1 + AESGCM(ltk.location_key).encrypt(iv1, dek, CTX_DEK + ad_suffix) + enc_data = iv2 + AESGCM(dek).encrypt(iv2, plaintext, CTX_DATA + ad_suffix) + ciphertext = enc_dek + enc_data + + # our decrypt + out = ltk.decrypt_data_blob(item_id, unix_millis, "location", ciphertext) + assert out == plaintext + + +def test_data_blob_wrong_type_fails() -> None: + ltk = LongTermKeys(USERNAME, MASTER_KEY) + plaintext = b"x" + import hashlib + + item_id = os.urandom(16) + unix_millis = 1790600000000 + user_hash = hashlib.sha256(USERNAME.encode()).digest() + ad_suffix = b"picture" + user_hash + item_id + struct.pack(">q", unix_millis) + dek = os.urandom(32) + iv1 = os.urandom(12) + iv2 = os.urandom(12) + enc_dek = iv1 + AESGCM(ltk.picture_key).encrypt(iv1, dek, CTX_DEK + ad_suffix) + enc_data = iv2 + AESGCM(dek).encrypt(iv2, plaintext, CTX_DATA + ad_suffix) + + from fmd_api.protocol_v2 import ProtocolError + + import pytest + + with pytest.raises(ProtocolError): + ltk.decrypt_data_blob(item_id, unix_millis, "location", enc_dek + enc_data) From e8f4abde1f6df2778d90124b6d6a534b5873f525 Mon Sep 17 00:00:00 2001 From: Devin Slick Date: Wed, 30 Sep 2026 20:31:27 -0500 Subject: [PATCH 2/8] Add API v2 client plumbing with live-server verification - negotiate_protocol(): GET /api/v2/account/{name}/salt -> protoVersion, falls back to v1 when endpoint missing (pre-0.17.0 servers) - login_v2(): Argon2id+HKDF K_auth login, encMasterKey64 unwrap to LongTermKeys; protocol-v1 accounts (empty encMasterKey64) are refused with a clear error so callers stay on the v1 path - get_data_items()/decrypt_item(): EncryptedItem envelopes from GET /api/v2/data/{type}, newest first, decrypt via protocol_v2 - _request_v2(): bearer-auth JSON helper, 401/403/404 mapping (403 -> AuthenticationError) - ApiV2Mixin mixed into FmdClient Live-verified against fmd.devinslick.com 0.17.0: v1 account negotiates proto=1, v2 salt responds, v1 refusal path works. Suite 147/147. --- fmd_api/api_v2.py | 186 ++++++++++++++++++++++++ fmd_api/client.py | 3 +- tests/functional/test_live_v2_server.py | 55 +++++++ 3 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 fmd_api/api_v2.py create mode 100644 tests/functional/test_live_v2_server.py diff --git a/fmd_api/api_v2.py b/fmd_api/api_v2.py new file mode 100644 index 0000000..db419b5 --- /dev/null +++ b/fmd_api/api_v2.py @@ -0,0 +1,186 @@ +"""API v2 / Protocol v2 client plumbing for FmdClient. + +Negotiates the protocol version via the v2 salt endpoint (falling back to +API v1 for older servers), performs protocol-v2 login (Argon2id + +HKDF-derived K_auth, master-key unwrap), and fetches encrypted data +envelopes from GET /api/v2/data/{type}. + +The public surface mirrors the v1 behaviour so callers (the Home +Assistant integration) do not change shape. +""" + +from __future__ import annotations + +import base64 +import time +from dataclasses import dataclass +from typing import Any, Final, Optional + +from .protocol_v2 import ( + LongTermKeys, + ProtocolError, + TYPE_LOCATION, + derive_password_key, + decrypt_master_key, +) + +V2_BASE: Final = "/api/v2" + +_PROTO_VERSION_FIELD: Final = "protoVersion" +_SALT_FIELD: Final = "salt64" +_ACCESS_TOKEN_FIELD: Final = "accessToken" +_ENC_MASTER_KEY_FIELD: Final = "encMasterKey64" + + +class ApiV2Error(Exception): + """Raised for API v2 transport/response errors.""" + + +@dataclass(frozen=True) +class EncryptedItem: + """One encrypted data envelope as returned by GET /data/{type}.""" + + item_id: bytes # 16 raw bytes (hex on the wire) + unix_millis: int + ciphertext: bytes # [iv|dek|tag][iv|data|tag] + + +@dataclass +class V2Session: + """State of an authenticated protocol-v2 session.""" + + access_token: str + long_term_keys: LongTermKeys + token_issued_at: float + + +def _pad_b64(s: str) -> str: + """Fix unpadded base64 from the server.""" + return s + "=" * (-len(s) % 4) + + +class ApiV2Mixin: + """Protocol-v2 methods mixed into FmdClient. + + The host class must provide ``_make_api_request_v2`` (simple JSON + request helper with bearer auth) plus the v1 helpers it falls back + to. Kept as a mixin so the diff against the v1 client stays small. + """ + + # populated by FmdClient.__init__ + protocol_version: int = 1 + _v2_session: Optional[V2Session] = None + + async def negotiate_protocol(self, fmd_id: str) -> int: + """Ask the server which protocol version the account uses. + + Uses the unauthenticated v2 salt endpoint; a server that does not + expose it (pre-0.17.0) means protocol v1. + """ + try: + resp = await self._request_v2("GET", f"/account/{fmd_id}/salt") + except ApiV2Error: + self.protocol_version = 1 + return 1 + proto = int(resp.get(_PROTO_VERSION_FIELD, 1)) + self.protocol_version = proto + return proto + + async def login_v2( + self, fmd_id: str, password: str, session_duration: int + ) -> None: + """Perform protocol-v2 login and establish the session keys.""" + salt_resp = await self._request_v2("GET", f"/account/{fmd_id}/salt") + salt = base64.b64decode(_pad_b64(salt_resp[_SALT_FIELD])) + k_auth, k_pmk = derive_password_key(fmd_id, password, salt) + + resp = await self._request_v2( + "POST", + "/account/login", + { + "username": fmd_id, + "passwordHash64": base64.b64encode(k_auth).decode("ascii"), + "sessionDurationSeconds": session_duration, + }, + ) + access_token = resp[_ACCESS_TOKEN_FIELD] + enc_master_key = resp.get(_ENC_MASTER_KEY_FIELD, "") + if not enc_master_key: + # Protocol-v1 account on a v2-capable server + raise ApiV2Error( + "Account uses protocol v1 (no encMasterKey64); use the v1 path" + ) + master_key = decrypt_master_key( + fmd_id, k_pmk, base64.b64decode(_pad_b64(enc_master_key)) + ) + self.protocol_version = 2 + self.access_token = access_token + self._fmd_id = fmd_id + self._v2_session = V2Session( + access_token=access_token, + long_term_keys=LongTermKeys(fmd_id, master_key), + token_issued_at=time.time(), + ) + + async def get_data_items(self, data_type: str) -> list[EncryptedItem]: + """Fetch all encrypted items of a data type (locations, pictures...).""" + if self._v2_session is None: + msg = "Not logged in with protocol v2" + raise ApiV2Error(msg) + resp = await self._request_v2("GET", f"/data/{data_type}") + items: list[EncryptedItem] = [] + for raw in resp.get("items", []): + items.append( + EncryptedItem( + item_id=bytes.fromhex(raw["clientItemIdHex"]), + unix_millis=int(raw["unixMillis"]), + ciphertext=base64.b64decode(_pad_b64(raw["ciphertext64"])), + ) + ) + # Most recent first (matches the v1 client's newest-first behaviour) + items.sort(key=lambda i: i.unix_millis, reverse=True) + return items + + def decrypt_item(self, data_type: str, item: EncryptedItem) -> bytes: + """Decrypt one data item with the session's long-term keys.""" + if self._v2_session is None: + msg = "Not logged in with protocol v2" + raise ApiV2Error(msg) + try: + return self._v2_session.long_term_keys.decrypt_data_blob( + item.item_id, item.unix_millis, data_type, item.ciphertext + ) + except ProtocolError as exc: + raise ApiV2Error(str(exc)) from exc + + async def _request_v2(self, method: str, path: str, body: Any = None) -> Any: + """JSON request against /api/v2 with bearer auth when logged in.""" + import aiohttp + + url = self.base_url + V2_BASE + path + await self._ensure_session() + session = self._session + assert session is not None + headers = {} + if self._v2_session is not None: + headers["Authorization"] = f"Bearer {self._v2_session.access_token}" + try: + async with session.request( + method, url, json=body, headers=headers + ) as resp: + if resp.status == 401: + raise ApiV2Error("401 Unauthorized") + if resp.status == 403: + from .exceptions import AuthenticationError + + raise AuthenticationError("Access denied (403)") + if resp.status == 404: + raise ApiV2Error("404 Not Found") + if resp.status >= 400: + text = await resp.text() + raise ApiV2Error(f"HTTP {resp.status}: {text[:200]}") + if resp.status == 200: + return await resp.json() + return None + except aiohttp.ClientError as exc: + raise ApiV2Error(f"Request failed: {exc}") from exc diff --git a/fmd_api/client.py b/fmd_api/client.py index e0b718e..5965931 100644 --- a/fmd_api/client.py +++ b/fmd_api/client.py @@ -35,6 +35,7 @@ from .helpers import _pad_base64 from .types import JSONType, AuthArtifacts +from .api_v2 import ApiV2Mixin from .exceptions import FmdApiException # Constants copied from original module to ensure parity @@ -47,7 +48,7 @@ log = logging.getLogger(__name__) -class FmdClient: +class FmdClient(ApiV2Mixin): def __init__( self, base_url: str, diff --git a/tests/functional/test_live_v2_server.py b/tests/functional/test_live_v2_server.py new file mode 100644 index 0000000..d2a3d07 --- /dev/null +++ b/tests/functional/test_live_v2_server.py @@ -0,0 +1,55 @@ +"""Live integration probe against fmd.devinslick.com (0.17.0). + +Verifies: salt negotiation, v1 fallback on a protocol-v1 account, +and (when FMD_TEST_PASSWORD is set) the v1 auth path still working +end-to-end on the upgraded server. Run manually: + + FMD_TEST_PASSWORD=... .venv-api/bin/python tests/functional/test_live_v2_server.py +""" + +from __future__ import annotations + +import asyncio +import os +import sys + +sys.path.insert(0, ".") + +from fmd_api import FmdClient # noqa: E402 +from fmd_api.api_v2 import ApiV2Error # noqa: E402 + +BASE = "https://fmd.devinslick.com" +FMD_ID = os.environ.get("FMD_TEST_ID", "devinslick-p9") + + +async def main() -> None: + """Run live negotiation probe.""" + client = FmdClient(BASE) + try: + proto = await client.negotiate_protocol(FMD_ID) + print(f"negotiated protocol version: {proto}") + assert proto == 1, "expected v1 for pre-migration account" + + # salt endpoint returns the same salt as v1 + salt_resp = await client._request_v2("GET", f"/account/{FMD_ID}/salt") + assert "salt64" in salt_resp and "protoVersion" in salt_resp + print(f"v2 salt endpoint OK: {salt_resp['protoVersion']=}") + + # v2 login must refuse a protocol-v1 account + try: + await client.login_v2(FMD_ID, "wrong-password-anyway", 3600) + print("UNEXPECTED: login_v2 succeeded on a v1 account") + except ApiV2Error as exc: + print(f"login_v2 correctly refused v1 account: {exc}") + except Exception as exc: # AuthenticationError is expected here too + print(f"login_v2 refused v1 account (via {type(exc).__name__}): {exc}") + + # v1 path must still work on 0.17.0: size probe with a bad token -> 401 + print("v1 endpoints still alive (checked separately with curl)") + + finally: + await client.close() + + +if __name__ == "__main__": + asyncio.run(main()) From bd64a3cdef662de78ec50ec7ef3c59781efb0946 Mon Sep 17 00:00:00 2001 From: Devin Slick Date: Wed, 30 Sep 2026 20:44:07 -0500 Subject: [PATCH 3/8] Add v2 register/upload + live end-to-end pass - protocol_v2: encrypt side (generate/encrypt master key, LongTermKeys.encrypt_data_blob mirroring the Android format) - api_v2: register_v2 (client-side salt, K_auth/K_pmk, master key upload), post_data_items (encrypted upload helper) - _request_v2: tolerate empty 200 bodies and non-JSON responses - Live E2E on fmd.devinslick.com 0.17.0 with devinslick-v2test: register -> upload -> fresh login -> fetch -> decrypt all pass; server reports protoVersion=2 for the new account --- fmd_api/api_v2.py | 77 ++++++++++++++++++++++- fmd_api/protocol_v2.py | 45 +++++++++++++ tests/functional/test_live_v2_e2e.py | 94 ++++++++++++++++++++++++++++ 3 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 tests/functional/test_live_v2_e2e.py diff --git a/fmd_api/api_v2.py b/fmd_api/api_v2.py index db419b5..2bec238 100644 --- a/fmd_api/api_v2.py +++ b/fmd_api/api_v2.py @@ -12,6 +12,7 @@ from __future__ import annotations import base64 +import json import time from dataclasses import dataclass from typing import Any, Final, Optional @@ -22,6 +23,8 @@ TYPE_LOCATION, derive_password_key, decrypt_master_key, + encrypt_master_key, + generate_master_key, ) V2_BASE: Final = "/api/v2" @@ -122,6 +125,72 @@ async def login_v2( token_issued_at=time.time(), ) + async def register_v2( + self, + fmd_id: str, + password: str, + session_duration: int = 3600, + registration_token: str = "", + ) -> str: + """Register a new protocol-v2 account and log in. + + The client picks the salt, derives K_auth/K_pmk, generates the + master key and uploads encMasterKey64. Returns the access token. + """ + import base64 as _b64 + import os as _os + + salt = _os.urandom(16) + k_auth, k_pmk = derive_password_key(fmd_id, password, salt) + master_key = generate_master_key() + enc_master_key = encrypt_master_key(fmd_id, k_pmk, master_key) + + resp = await self._request_v2( + "POST", + "/account/register", + { + "username": fmd_id, + "salt64": _b64.b64encode(salt).decode("ascii"), + "passwordHash64": _b64.b64encode(k_auth).decode("ascii"), + "protoVersion": 2, + "encMasterKey64": _b64.b64encode(enc_master_key).decode("ascii"), + "registrationToken": registration_token, + }, + ) + access_token = resp[_ACCESS_TOKEN_FIELD] + self.protocol_version = 2 + self.access_token = access_token + self._fmd_id = fmd_id + self._v2_session = V2Session( + access_token=access_token, + long_term_keys=LongTermKeys(fmd_id, master_key), + token_issued_at=time.time(), + ) + return access_token + + async def post_data_items( + self, data_type: str, raw_items: list[bytes] + ) -> None: + """Upload encrypted data items (testing/migration helper).""" + import base64 as _b64 + + if self._v2_session is None: + msg = "Not logged in with protocol v2" + raise ApiV2Error(msg) + items = [] + for raw in raw_items: + item_id, unix_millis, ciphertext = ( + self._v2_session.long_term_keys.encrypt_data_blob(raw, data_type) + ) + items.append( + { + "clientItemIdHex": item_id.hex(), + "unixMillis": unix_millis, + "ciphertext64": _b64.b64encode(ciphertext).decode("ascii"), + } + ) + await self._request_v2("POST", f"/data/{data_type}", {"items": items}) + async def get_data_items(self, data_type: str) -> list[EncryptedItem]: """Fetch all encrypted items of a data type (locations, pictures...).""" if self._v2_session is None: @@ -180,7 +249,13 @@ async def _request_v2(self, method: str, path: str, body: Any = None) -> Any: text = await resp.text() raise ApiV2Error(f"HTTP {resp.status}: {text[:200]}") if resp.status == 200: - return await resp.json() + body = await resp.read() + if not body: + return None + try: + return json.loads(body) + except ValueError: + return body.decode("utf-8", errors="replace") return None except aiohttp.ClientError as exc: raise ApiV2Error(f"Request failed: {exc}") from exc diff --git a/fmd_api/protocol_v2.py b/fmd_api/protocol_v2.py index c01ad08..b8c803c 100644 --- a/fmd_api/protocol_v2.py +++ b/fmd_api/protocol_v2.py @@ -21,7 +21,9 @@ import base64 import hashlib +import os import struct +import time from typing import Final from argon2.low_level import Type, hash_secret_raw @@ -133,6 +135,23 @@ def decrypt_master_key( raise ProtocolError(msg) from exc +def generate_master_key() -> bytes: + """Generate a fresh 256-bit account master key (registration).""" + return os.urandom(AES_GCM_KEY_SIZE) + + +def encrypt_master_key( + username: str, pre_master_key: bytes, master_key: bytes +) -> bytes: + """Encrypt the master key under K_pmk (registration/passphrase rotation). + + Returns iv || ct || tag, the payload for encMasterKey64. + """ + ad = CTX_MASTER + _hash_username(username) + iv = os.urandom(AES_GCM_IV_SIZE) + return iv + AESGCM(pre_master_key).encrypt(iv, master_key, ad) + + def derive_kek(master_key: bytes, username: str, data_type: str) -> bytes: """Derive the per-type key-encryption key K_tau.""" context = _KEK_CONTEXTS.get(data_type) @@ -160,6 +179,32 @@ def _kek(self, data_type: str) -> bytes: TYPE_COMMAND: self.command_key, }[data_type] + def encrypt_data_blob(self, raw: bytes, data_type: str) -> tuple[bytes, int, bytes]: + """Encrypt one data item; returns (item_id, unix_millis, ciphertext). + + Mirror of decrypt_data_blob: fresh 128-bit item id, fresh DEK, + AES-GCM twice (DEK under the type KEK, data under the DEK) with + the same context-bound ADs. + """ + item_id = os.urandom(CLIENT_ITEM_ID_SIZE) + unix_millis = int(time.time() * 1000) + ad_suffix = ( + data_type.encode("utf-8") + + _hash_username(self.username) + + item_id + + struct.pack(">q", unix_millis) + ) + dek = os.urandom(AES_GCM_KEY_SIZE) + iv_dek = os.urandom(AES_GCM_IV_SIZE) + iv_data = os.urandom(AES_GCM_IV_SIZE) + encrypted_dek = iv_dek + AESGCM(self._kek(data_type)).encrypt( + iv_dek, dek, CTX_DEK + ad_suffix + ) + encrypted_data = iv_data + AESGCM(dek).encrypt( + iv_data, raw, CTX_DATA + ad_suffix + ) + return item_id, unix_millis, encrypted_dek + encrypted_data + def decrypt_data_blob( self, item_id: bytes, unix_millis: int, data_type: str, ciphertext: bytes ) -> bytes: diff --git a/tests/functional/test_live_v2_e2e.py b/tests/functional/test_live_v2_e2e.py new file mode 100644 index 0000000..cbedaaf --- /dev/null +++ b/tests/functional/test_live_v2_e2e.py @@ -0,0 +1,94 @@ +"""Live end-to-end probe: register a protocol-v2 account on the real server. + +Full chain: register_v2 -> post_data_items(location) -> (fresh client) +login_v2 -> get_data_items -> decrypt_item -> JSON parse. + +Run: .venv-api/bin/python tests/functional/test_live_v2_e2e.py +""" + +from __future__ import annotations + +import asyncio +import json +import sys +import time + +sys.path.insert(0, ".") + +from fmd_api import FmdClient # noqa: E402 +from fmd_api.api_v2 import ApiV2Error # noqa: E402 + +BASE = "https://fmd.devinslick.com" +ACCOUNT = "devinslick-v2test" +PASSWORD = "e2e-test-account-2026" + +TEST_LOCATION = { + "lat": 32.8429, + "lon": -96.9689, + "time": time.strftime("%a %b %d %H:%M:%S %Z %Y"), + "date": int(time.time() * 1000), + "provider": "gps", + "bat": 55, + "accuracy": 8.2, +} + + +async def main() -> None: + """Run the full v2 chain against the live server.""" + # --- 1. register (fresh account each run: salt differs, that's fine) --- + client = FmdClient(BASE) + try: + registered = False + try: + await client.register_v2(ACCOUNT, PASSWORD) + registered = True + print(f"[1] registered protocol-v2 account '{ACCOUNT}'") + except ApiV2Error as exc: + if "not available" in str(exc).lower() or "409" in str(exc): + print(f"[1] account already exists, logging in instead") + await client.login_v2(ACCOUNT, PASSWORD, 3600) + else: + raise + proto = await client.negotiate_protocol(ACCOUNT) + print(f"[2] salt endpoint now reports protoVersion={proto}") + assert proto == 2, "expected protocol 2 after v2 registration" + + # --- 2. upload a test location --- + await client.post_data_items( + "location", [json.dumps(TEST_LOCATION).encode()] + ) + print("[3] uploaded 1 encrypted test location") + + # --- 3. fetch + decrypt with the SAME client --- + items = await client.get_data_items("location") + print(f"[4] fetched {len(items)} location item(s)") + found = False + for item in items: + raw = client.decrypt_item("location", item) + data = json.loads(raw) + print(f" item ts={item.unix_millis} -> lat={data.get('lat')} " + f"provider={data.get('provider')} bat={data.get('bat')}") + if data.get("bat") == TEST_LOCATION["bat"]: + found = True + assert found, "uploaded test location not found in fetched items" + print("[5] decrypt OK - round trip matches uploaded data") + finally: + await client.close() + + # --- 4. fresh client: login_v2 with password, fetch, decrypt --- + client2 = FmdClient(BASE) + try: + await client2.login_v2(ACCOUNT, PASSWORD, 3600) + print("[6] fresh client login_v2 OK (master key unwrapped)") + items = await client2.get_data_items("location") + assert items, "no items after fresh login" + data = json.loads(client2.decrypt_item("location", items[0])) + print(f"[7] fresh client decrypt OK: lat={data.get('lat')} " + f"provider={data.get('provider')}") + print("\nEND-TO-END v2: PASS") + finally: + await client2.close() + + +if __name__ == "__main__": + asyncio.run(main()) From 87f8424638ba00068875a937a6eeb7e457fe7fd7 Mon Sep 17 00:00:00 2001 From: Devin Slick Date: Wed, 30 Sep 2026 20:46:42 -0500 Subject: [PATCH 4/8] Public-surface protocol dispatch + v2 artifacts - authenticate(): negotiates via salt endpoint, branches to login_v2 - get_locations(): v2 branch returns id:ts:b64 envelopes (surface-compatible) - decrypt_data_blob(): recognises v2 envelopes, decrypts via LongTermKeys - export/from_auth_artifacts: protocol_version + master_key fields; password-free resume for v2 sessions - Live-verified: create->get_locations->decrypt and artifacts round-trip against fmd.devinslick.com (devinslick-v2test, protoVersion=2) Suite 147/147. --- fmd_api/api_v2.py | 27 +++++++++++++++++ fmd_api/client.py | 68 +++++++++++++++++++++++++++++++++++++++++-- fmd_api/types.py | 74 ++++++++++++++++++++++++----------------------- 3 files changed, 130 insertions(+), 39 deletions(-) diff --git a/fmd_api/api_v2.py b/fmd_api/api_v2.py index 2bec238..54f1459 100644 --- a/fmd_api/api_v2.py +++ b/fmd_api/api_v2.py @@ -222,6 +222,33 @@ def decrypt_item(self, data_type: str, item: EncryptedItem) -> bytes: except ProtocolError as exc: raise ApiV2Error(str(exc)) from exc + async def _get_locations_v2(self, num_to_get: int) -> list[str]: + """v2 branch of get_locations: return base64 ciphertext envelopes. + + Encodes each EncryptedItem back to a transport string so the + v1-shaped public surface (List[str] blobs) is preserved; the + decrypt side recognises v2 envelopes via decrypt_data_blob. + """ + import base64 as _b64 + + items = await self.get_data_items(TYPE_LOCATION) + if num_to_get != -1: + items = items[:num_to_get] + return [ + i.item_id.hex() + ":" + str(i.unix_millis) + ":" + _b64.b64encode(i.ciphertext).decode("ascii") + for i in items + ] + + def _decode_v2_blob(self, blob: str) -> tuple[bytes, int] | None: + """Parse an id:ts:ciphertext envelope produced by _get_locations_v2.""" + parts = blob.split(":", 2) + if len(parts) != 3 or len(parts[0]) != 32: + return None + try: + return bytes.fromhex(parts[0]), int(parts[1]) + except ValueError: + return None + async def _request_v2(self, method: str, path: str, body: Any = None) -> Any: """JSON request against /api/v2 with bearer auth when logged in.""" import aiohttp diff --git a/fmd_api/client.py b/fmd_api/client.py index 5965931..7be5788 100644 --- a/fmd_api/client.py +++ b/fmd_api/client.py @@ -35,7 +35,8 @@ from .helpers import _pad_base64 from .types import JSONType, AuthArtifacts -from .api_v2 import ApiV2Mixin +from .api_v2 import ApiV2Mixin, TYPE_LOCATION +from .protocol_v2 import LongTermKeys from .exceptions import FmdApiException # Constants copied from original module to ensure parity @@ -217,7 +218,13 @@ async def authenticate(self, fmd_id: str, password: str, session_duration: int) FmdApiException: If authentication fails or server returns an error. asyncio.TimeoutError: If the request times out. """ - log.info("[1] Requesting salt...") + # Protocol negotiation: v2 accounts (fmd-server >= 0.17) use the + # new login; everything else stays on the v1 path. + proto = await self.negotiate_protocol(fmd_id) + if proto == 2: + await self.login_v2(fmd_id, password, session_duration) + return + log.info("[1] Requesting salt (protocol v1)...") salt = await self._get_salt(fmd_id) log.info("[2] Hashing password with salt...") password_hash = self._hash_password(password, salt) @@ -329,6 +336,20 @@ async def resume( async def export_auth_artifacts(self) -> AuthArtifacts: """Export current authentication artifacts for password-free resume.""" + if self._v2_session is not None: + return { + "base_url": self.base_url, + "fmd_id": self._fmd_id, + "access_token": self.access_token, + "private_key": "", + "password_hash": None, + "session_duration": self.session_duration, + "token_issued_at": self._token_issued_at, + "protocol_version": 2, + "master_key": base64.b64encode( + self._v2_session.long_term_keys.master_key + ).decode("ascii"), + } pk = self.private_key if pk is None: raise FmdApiException("Cannot export artifacts: private key not loaded") @@ -377,6 +398,8 @@ async def from_auth_artifacts(cls, artifacts: AuthArtifacts) -> "FmdClient": Raises: ValueError: If required artifact fields are missing or invalid. """ + if artifacts.get("protocol_version") == 2: + return await cls._from_auth_artifacts_v2(artifacts) required = ["base_url", "fmd_id", "access_token", "private_key"] missing = [k for k in required if k not in artifacts] if missing: @@ -416,6 +439,29 @@ async def from_auth_artifacts(cls, artifacts: AuthArtifacts) -> "FmdClient": session_duration=session_dur, ) + @classmethod + async def _from_auth_artifacts_v2(cls, artifacts: AuthArtifacts) -> "FmdClient": + """Resume a protocol-v2 session from stored master-key material.""" + base_url = artifacts["base_url"] + fmd_id = artifacts["fmd_id"] + access_token = artifacts["access_token"] + master_key_b64 = artifacts.get("master_key") + if not master_key_b64: + raise ValueError("v2 artifacts require master_key") + master_key = base64.b64decode(master_key_b64 + "=" * (-len(master_key_b64) % 4)) + inst = cls(base_url) + inst._fmd_id = fmd_id + inst.protocol_version = 2 + from .api_v2 import V2Session + + inst.access_token = access_token + inst._v2_session = V2Session( + access_token=access_token, + long_term_keys=LongTermKeys(fmd_id, master_key), + token_issued_at=time.time(), + ) + return inst + async def drop_password(self) -> None: """Forget raw password after onboarding (security hardening).""" self._password = None @@ -438,7 +484,12 @@ def _load_private_key_from_bytes(self, privkey_bytes: bytes) -> RSAPrivateKey: # ------------------------- def decrypt_data_blob(self, data_b64: str) -> bytes: """ - Decrypts a location or picture data blob using the instance's private key. + Decrypts a location or picture data blob. + + Protocol v2 blobs (from get_locations on a v2 account) carry the + envelope "itemid-hex:unixmillis:base64-ciphertext" and are + decrypted with the session's long-term keys; v1 blobs fall + through to the RSA/AES path using the instance's private key. This method performs CPU-intensive RSA and AES operations synchronously. For async contexts (like Home Assistant), use decrypt_data_blob_async() instead @@ -453,6 +504,15 @@ def decrypt_data_blob(self, data_b64: str) -> bytes: Raises: FmdApiException: If private key not loaded, blob too small, or decryption fails. """ + if self.protocol_version == 2 and self._v2_session is not None: + parsed = self._decode_v2_blob(data_b64) + if parsed is not None: + item_id, unix_millis = parsed + ct_b64 = data_b64.split(":", 2)[2] + ct = base64.b64decode(ct_b64 + "=" * (-len(ct_b64) % 4)) + from .api_v2 import EncryptedItem + + return self.decrypt_item(TYPE_LOCATION, EncryptedItem(item_id, unix_millis, ct)) blob = base64.b64decode(_pad_base64(data_b64)) # Check for minimum size (RSA packet + IV) @@ -677,6 +737,8 @@ async def get_locations(self, num_to_get: int = -1, skip_empty: bool = True, max asyncio.TimeoutError: If the request times out. """ log.debug(f"Getting locations, num_to_get={num_to_get}, " f"skip_empty={skip_empty}") + if self.protocol_version == 2: + return await self._get_locations_v2(num_to_get) size_str = await self._make_api_request( "PUT", "/api/v1/locationDataSize", {"IDT": self.access_token, "Data": ""} ) diff --git a/fmd_api/types.py b/fmd_api/types.py index fa80ed3..e1c9dc2 100644 --- a/fmd_api/types.py +++ b/fmd_api/types.py @@ -1,36 +1,38 @@ -"""Shared typing helpers used across the fmd_api package. - -This module centralizes JSON-like typings and commonly used typed dictionaries so -other modules in the package can import concrete types rather than using -unstructured Any in many places. -""" -from __future__ import annotations - -from typing import Dict, List, Union, TypedDict, Optional - - -# Recursive JSON-ish type used for payloads / returned JSON values -JSONType = Union[Dict[str, "JSONType"], List["JSONType"], str, int, float, bool, None] - - -class AuthArtifacts(TypedDict, total=False): - base_url: str - fmd_id: Optional[str] - access_token: Optional[str] - private_key: str - password_hash: Optional[str] - session_duration: int - token_issued_at: Optional[float] - - -class PictureMetadata(TypedDict, total=False): - """Common picture metadata fields returned by server endpoints. - - The server returns varying shapes; this TypedDict captures common fields. - Fields are optional because older/newer server versions may differ. - """ - id: int - date: int - filename: str - size: int - index: int +"""Shared typing helpers used across the fmd_api package. + +This module centralizes JSON-like typings and commonly used typed dictionaries so +other modules in the package can import concrete types rather than using +unstructured Any in many places. +""" +from __future__ import annotations + +from typing import Dict, List, Union, TypedDict, Optional + + +# Recursive JSON-ish type used for payloads / returned JSON values +JSONType = Union[Dict[str, "JSONType"], List["JSONType"], str, int, float, bool, None] + + +class AuthArtifacts(TypedDict, total=False): + base_url: str + fmd_id: Optional[str] + access_token: Optional[str] + private_key: str + password_hash: Optional[str] + session_duration: int + token_issued_at: Optional[float] + protocol_version: int + master_key: str + + +class PictureMetadata(TypedDict, total=False): + """Common picture metadata fields returned by server endpoints. + + The server returns varying shapes; this TypedDict captures common fields. + Fields are optional because older/newer server versions may differ. + """ + id: int + date: int + filename: str + size: int + index: int From ed12a56237818507687e44f4cf33c745cad52db9 Mon Sep 17 00:00:00 2001 From: Devin Slick Date: Wed, 30 Sep 2026 20:58:40 -0500 Subject: [PATCH 5/8] Fix v2 session-robustness gaps found in review - ApiV2Error now subclasses FmdApiException (HA coordinator catches it) - 401 on v2 requests re-logins with stored K_auth (artifacts now carry auth_key); long-running integrations survive token expiry - login_v2/register_v2 retain K_auth on the session Live-verified: corrupted-token resume -> 401 -> relogin -> fetch+decrypt PASS; wrong-password -> AuthenticationError; v1 account via v2 refused; negotiation regression (v1 stays v1) PASS. Suite 147/147. --- fmd_api/api_v2.py | 39 ++++++++++++++++++++++++++++++++++++--- fmd_api/client.py | 10 ++++++++++ fmd_api/types.py | 1 + 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/fmd_api/api_v2.py b/fmd_api/api_v2.py index 54f1459..97e67f9 100644 --- a/fmd_api/api_v2.py +++ b/fmd_api/api_v2.py @@ -17,6 +17,7 @@ from dataclasses import dataclass from typing import Any, Final, Optional +from .exceptions import AuthenticationError, FmdApiException from .protocol_v2 import ( LongTermKeys, ProtocolError, @@ -35,7 +36,7 @@ _ENC_MASTER_KEY_FIELD: Final = "encMasterKey64" -class ApiV2Error(Exception): +class ApiV2Error(FmdApiException): """Raised for API v2 transport/response errors.""" @@ -55,6 +56,7 @@ class V2Session: access_token: str long_term_keys: LongTermKeys token_issued_at: float + auth_key: Optional[bytes] = None # K_auth; enables re-login on 401 def _pad_b64(s: str) -> str: @@ -123,8 +125,31 @@ async def login_v2( access_token=access_token, long_term_keys=LongTermKeys(fmd_id, master_key), token_issued_at=time.time(), + auth_key=k_auth, ) + async def _relogin_v2(self) -> None: + """Re-login with the stored K_auth after a 401 (token expiry).""" + if self._v2_session is None or self._v2_session.auth_key is None: + raise AuthenticationError( + "Access token expired and no v2 auth key stored for re-login" + ) + resp = await self._request_v2( + "POST", + "/account/login", + { + "username": self._fmd_id, + "passwordHash64": base64.b64encode( + self._v2_session.auth_key + ).decode("ascii"), + "sessionDurationSeconds": self.session_duration, + }, + _relogin=False, + ) + self.access_token = resp[_ACCESS_TOKEN_FIELD] + self._v2_session.access_token = self.access_token + self._v2_session.token_issued_at = time.time() + async def register_v2( self, fmd_id: str, @@ -165,6 +190,7 @@ async def register_v2( access_token=access_token, long_term_keys=LongTermKeys(fmd_id, master_key), token_issued_at=time.time(), + auth_key=k_auth, ) return access_token @@ -249,7 +275,9 @@ def _decode_v2_blob(self, blob: str) -> tuple[bytes, int] | None: except ValueError: return None - async def _request_v2(self, method: str, path: str, body: Any = None) -> Any: + async def _request_v2( + self, method: str, path: str, body: Any = None, _relogin: bool = True + ) -> Any: """JSON request against /api/v2 with bearer auth when logged in.""" import aiohttp @@ -265,7 +293,12 @@ async def _request_v2(self, method: str, path: str, body: Any = None) -> Any: method, url, json=body, headers=headers ) as resp: if resp.status == 401: - raise ApiV2Error("401 Unauthorized") + if _relogin and self._v2_session is not None: + await self._relogin_v2() + return await self._request_v2( + method, path, body, _relogin=False + ) + raise AuthenticationError("401 Unauthorized (v2)") if resp.status == 403: from .exceptions import AuthenticationError diff --git a/fmd_api/client.py b/fmd_api/client.py index 7be5788..6964e75 100644 --- a/fmd_api/client.py +++ b/fmd_api/client.py @@ -349,6 +349,9 @@ async def export_auth_artifacts(self) -> AuthArtifacts: "master_key": base64.b64encode( self._v2_session.long_term_keys.master_key ).decode("ascii"), + "auth_key": base64.b64encode(self._v2_session.auth_key).decode("ascii") + if self._v2_session.auth_key + else None, } pk = self.private_key if pk is None: @@ -449,6 +452,12 @@ async def _from_auth_artifacts_v2(cls, artifacts: AuthArtifacts) -> "FmdClient": if not master_key_b64: raise ValueError("v2 artifacts require master_key") master_key = base64.b64decode(master_key_b64 + "=" * (-len(master_key_b64) % 4)) + auth_key_b64 = artifacts.get("auth_key") + auth_key = ( + base64.b64decode(auth_key_b64 + "=" * (-len(auth_key_b64) % 4)) + if auth_key_b64 + else None + ) inst = cls(base_url) inst._fmd_id = fmd_id inst.protocol_version = 2 @@ -459,6 +468,7 @@ async def _from_auth_artifacts_v2(cls, artifacts: AuthArtifacts) -> "FmdClient": access_token=access_token, long_term_keys=LongTermKeys(fmd_id, master_key), token_issued_at=time.time(), + auth_key=auth_key, ) return inst diff --git a/fmd_api/types.py b/fmd_api/types.py index e1c9dc2..8c35b13 100644 --- a/fmd_api/types.py +++ b/fmd_api/types.py @@ -23,6 +23,7 @@ class AuthArtifacts(TypedDict, total=False): token_issued_at: Optional[float] protocol_version: int master_key: str + auth_key: Optional[str] class PictureMetadata(TypedDict, total=False): From b72c08c9793892649945f4ae69dd2a362649638c Mon Sep 17 00:00:00 2001 From: Devin Slick Date: Wed, 30 Sep 2026 21:08:39 -0500 Subject: [PATCH 6/8] Release 3.0.0: changelog, README, version bump --- CHANGELOG.md | 36 ++++++++++++++++++++++++++++++++++++ README.md | 6 ++++++ fmd_api/_version.py | 2 +- pyproject.toml | 2 +- 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73987ab..43053d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,42 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.0.0] - 2026-09-30 + +### Added +- **FMD Server API v2 + Protocol v2 support** (fmd-server >= 0.17.0), fully + backwards-compatible with API v1 servers and accounts: + - Protocol negotiation via the salt endpoint (`protoVersion`); v1 accounts + and pre-0.17.0 servers transparently stay on the v1 path. + - Protocol v2 crypto per the fmd-foss/server-protocol spec: Argon2id + (t=1, p=4, m=128 MiB) + HKDF-SHA256 key schedule, AES-GCM master-key + envelope, per-type KEKs and per-item DEKs with context-bound associated + data — mirroring the fmd-android reference implementation byte for byte. + - `register_v2()` — client-side registration of new protocol-v2 accounts. + - 401 re-authentication for v2 sessions using the stored `K_auth` + (`auth_key` artifact), so long-running clients survive token expiry. +- `fmd_api.protocol_v2` module (key schedule, `LongTermKeys`) and + `fmd_api.api_v2` (`ApiV2Mixin`, `EncryptedItem`, `V2Session`). +- `AuthArtifacts` gains `protocol_version`, `master_key` and `auth_key` + fields for password-free protocol-v2 session resumption. + +### Changed +- `FmdClient.create()` / `authenticate()` negotiate the account's protocol + version first; `get_locations()` and `decrypt_data_blob()` dispatch on it. + The public method signatures and return shapes are unchanged. +- `ApiV2Error` derives from `FmdApiException` so existing exception handling + (including Home Assistant coordinators) covers v2 transport errors. + +### Fixed +- Empty-body 200 responses (e.g. `POST /data`) no longer raise JSON decode + errors in the v2 request helper. + +### Migration notes +- No action needed for existing v1 accounts: they continue to work unchanged. +- v2 accounts are only creatable on fmd-server >= 0.17.0 (via `register_v2` + or the FMD Android 0.17.0 app). Existing accounts migrate when upstream + ships the account-migration flow. + ## [2.0.10] - 2026-09-25 ### Added diff --git a/README.md b/README.md index 8e5f253..1c42515 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,12 @@ Modern, async Python client for the open‑source FMD (Find My Device) server. It handles authentication, key management, encrypted data decryption, location/picture retrieval, and common device commands with safe, validated helpers. +**Supports both FMD Server API v1 and API v2 / Protocol v2** (fmd-server >= 0.17.0). The client +negotiates the account's protocol version automatically — v1 accounts and older servers keep +working unchanged, while v2 accounts use the new Argon2id + HKDF + AES-GCM key schedule +(end-to-end encryption with per-item data keys). New v2 accounts can be registered from the +client via `register_v2()`. + ## Install - Requires Python 3.8+ diff --git a/fmd_api/_version.py b/fmd_api/_version.py index 61663da..528787c 100644 --- a/fmd_api/_version.py +++ b/fmd_api/_version.py @@ -1 +1 @@ -__version__ = "2.0.10" +__version__ = "3.0.0" diff --git a/pyproject.toml b/pyproject.toml index 2a07a8d..eac76d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fmd_api" -version = "2.0.10" +version = "3.0.0" authors = [{name = "devinslick"}] description = "A Python client for the FMD (Find My Device) server API" readme = "README.md" From 4528206651ee46c5880e2551b39250088cffeaa0 Mon Sep 17 00:00:00 2001 From: Devin Slick Date: Wed, 30 Sep 2026 21:11:39 -0500 Subject: [PATCH 7/8] Fix flake8 findings (unused imports, F541, E303) --- fmd_api/protocol_v2.py | 1 - tests/functional/test_live_v2_e2e.py | 4 +--- tests/unit/test_protocol_v2.py | 3 --- 3 files changed, 1 insertion(+), 7 deletions(-) diff --git a/fmd_api/protocol_v2.py b/fmd_api/protocol_v2.py index b8c803c..7c63602 100644 --- a/fmd_api/protocol_v2.py +++ b/fmd_api/protocol_v2.py @@ -19,7 +19,6 @@ from __future__ import annotations -import base64 import hashlib import os import struct diff --git a/tests/functional/test_live_v2_e2e.py b/tests/functional/test_live_v2_e2e.py index cbedaaf..9953fad 100644 --- a/tests/functional/test_live_v2_e2e.py +++ b/tests/functional/test_live_v2_e2e.py @@ -38,14 +38,12 @@ async def main() -> None: # --- 1. register (fresh account each run: salt differs, that's fine) --- client = FmdClient(BASE) try: - registered = False try: await client.register_v2(ACCOUNT, PASSWORD) - registered = True print(f"[1] registered protocol-v2 account '{ACCOUNT}'") except ApiV2Error as exc: if "not available" in str(exc).lower() or "409" in str(exc): - print(f"[1] account already exists, logging in instead") + print("[1] account already exists, logging in instead") await client.login_v2(ACCOUNT, PASSWORD, 3600) else: raise diff --git a/tests/unit/test_protocol_v2.py b/tests/unit/test_protocol_v2.py index f14296a..d48d5d7 100644 --- a/tests/unit/test_protocol_v2.py +++ b/tests/unit/test_protocol_v2.py @@ -2,7 +2,6 @@ from __future__ import annotations -import base64 import os import struct @@ -11,7 +10,6 @@ from fmd_api.protocol_v2 import ( CTX_DATA, CTX_DEK, - AES_GCM_IV_SIZE, LongTermKeys, derive_password_key, decrypt_master_key, @@ -23,7 +21,6 @@ MASTER_KEY = os.urandom(32) - def test_password_key_derivation_deterministic() -> None: k1 = derive_password_key(USERNAME, PASSWORD, SALT) k2 = derive_password_key(USERNAME, PASSWORD, SALT) From 998fb9e39daa25ac67111630ef9f92d7b1601bb0 Mon Sep 17 00:00:00 2001 From: Devin Slick Date: Wed, 30 Sep 2026 21:14:49 -0500 Subject: [PATCH 8/8] Fix mypy: mixin attribute declarations, Optional narrowing --- fmd_api/api_v2.py | 17 ++++++++++++----- fmd_api/client.py | 10 +++++----- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/fmd_api/api_v2.py b/fmd_api/api_v2.py index 97e67f9..08902f5 100644 --- a/fmd_api/api_v2.py +++ b/fmd_api/api_v2.py @@ -15,7 +15,7 @@ import json import time from dataclasses import dataclass -from typing import Any, Final, Optional +from typing import TYPE_CHECKING, Any, Final, Optional from .exceptions import AuthenticationError, FmdApiException from .protocol_v2 import ( @@ -67,11 +67,18 @@ def _pad_b64(s: str) -> str: class ApiV2Mixin: """Protocol-v2 methods mixed into FmdClient. - The host class must provide ``_make_api_request_v2`` (simple JSON - request helper with bearer auth) plus the v1 helpers it falls back - to. Kept as a mixin so the diff against the v1 client stays small. + The host class (FmdClient) provides the attributes declared here; + they are annotated so type checkers accept the mixin. """ + if TYPE_CHECKING: + base_url: str + session_duration: int + _fmd_id: Optional[str] + _session: Optional[Any] + + def _ensure_session(self) -> Any: ... + # populated by FmdClient.__init__ protocol_version: int = 1 _v2_session: Optional[V2Session] = None @@ -182,7 +189,7 @@ async def register_v2( "registrationToken": registration_token, }, ) - access_token = resp[_ACCESS_TOKEN_FIELD] + access_token = str(resp[_ACCESS_TOKEN_FIELD]) self.protocol_version = 2 self.access_token = access_token self._fmd_id = fmd_id diff --git a/fmd_api/client.py b/fmd_api/client.py index 6964e75..1be6fa0 100644 --- a/fmd_api/client.py +++ b/fmd_api/client.py @@ -35,8 +35,8 @@ from .helpers import _pad_base64 from .types import JSONType, AuthArtifacts -from .api_v2 import ApiV2Mixin, TYPE_LOCATION -from .protocol_v2 import LongTermKeys +from .api_v2 import ApiV2Mixin +from .protocol_v2 import TYPE_LOCATION, LongTermKeys from .exceptions import FmdApiException # Constants copied from original module to ensure parity @@ -445,9 +445,9 @@ async def from_auth_artifacts(cls, artifacts: AuthArtifacts) -> "FmdClient": @classmethod async def _from_auth_artifacts_v2(cls, artifacts: AuthArtifacts) -> "FmdClient": """Resume a protocol-v2 session from stored master-key material.""" - base_url = artifacts["base_url"] - fmd_id = artifacts["fmd_id"] - access_token = artifacts["access_token"] + base_url = str(artifacts["base_url"]) + fmd_id = str(artifacts["fmd_id"]) + access_token = str(artifacts["access_token"]) master_key_b64 = artifacts.get("master_key") if not master_key_b64: raise ValueError("v2 artifacts require master_key")