From d4144450f0631a7e9b794c9d7b9e414cb2b9c0ae Mon Sep 17 00:00:00 2001 From: jlengelbrecht Date: Wed, 13 May 2026 23:53:50 -0400 Subject: [PATCH 01/10] Initial Gradle scaffolding for JavaSake library Adds a single :lib subproject (java-library plugin, Java 11 source/target) with BouncyCastle and JUnit 5 dependencies wired via a version catalog. Bytecode targets Java 11 to match the Android module's existing sourceCompatibility setting and to keep the library consumable on API 24+. --- .editorconfig | 17 ++ .gitignore | 21 ++ README.md | 25 ++ build.gradle.kts | 2 + gradle.properties | 3 + gradle/libs.versions.toml | 8 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45457 bytes gradle/wrapper/gradle-wrapper.properties | 8 + gradlew | 251 ++++++++++++++++++ gradlew.bat | 94 +++++++ lib/build.gradle.kts | 30 +++ .../org/openminimed/sake/package-info.java | 7 + .../org/openminimed/sake/package-info.java | 4 + settings.gradle.kts | 3 + 14 files changed, 473 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 README.md create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 lib/build.gradle.kts create mode 100644 lib/src/main/java/org/openminimed/sake/package-info.java create mode 100644 lib/src/test/java/org/openminimed/sake/package-info.java create mode 100644 settings.gradle.kts diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..dadba56 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space + +[*.{java,kt,kts}] +indent_size = 4 + +[*.{xml,yml,yaml}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e417945 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Gradle +.gradle/ +build/ +out/ + +# IDE +.idea/ +*.iml +*.ipr +*.iws +.classpath +.project +.settings/ +.vscode/ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..5bc75ac --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# JavaSake + +Java port of the SAKE handshake protocol used by 700-series Medtronic pumps. + +Mirrors the public surface of [PythonSake](https://github.com/OpenMinimed/PythonSake) +so it can be consumed by the [JavaPumpConnector](https://github.com/OpenMinimed/JavaPumpConnector) +Android application (and any other JVM project that needs to drive a SAKE handshake). + +## Build + +```sh +./gradlew build +``` + +## Test + +```sh +./gradlew test +``` + +Requires JDK 11 or later. + +## License + +GPL-3.0. See [`LICENSE`](LICENSE). diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..0cda148 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,2 @@ +// Root project intentionally has no plugins applied. +// See `lib/build.gradle.kts` for the library configuration. diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..2934ae5 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..97510d2 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,8 @@ +[versions] +junit = "5.11.3" +bouncycastle = "1.79" + +[libraries] +junit-jupiter-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit" } +junit-jupiter-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junit" } +bouncycastle = { group = "org.bouncycastle", name = "bcprov-jdk18on", version.ref = "bouncycastle" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..8bdaf60c75ab801e22807dde59e12a8735a34077 GIT binary patch literal 45457 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 c82=K%QXLKFx%@O{wJjEi6Y56o#$)Bpeg literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..6a38a8c --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionSha256Sum=a17ddd85a26b6a7f5ddb71ff8b05fc5104c0202c6e64782429790c933686c806 +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..ef07e01 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/lib/build.gradle.kts b/lib/build.gradle.kts new file mode 100644 index 0000000..889118f --- /dev/null +++ b/lib/build.gradle.kts @@ -0,0 +1,30 @@ +plugins { + `java-library` +} + +group = "org.openminimed" +version = "0.1.0-SNAPSHOT" + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + withSourcesJar() +} + +repositories { + mavenCentral() +} + +dependencies { + implementation(libs.bouncycastle) + + testImplementation(libs.junit.jupiter.api) + testRuntimeOnly(libs.junit.jupiter.engine) +} + +tasks.test { + useJUnitPlatform() + testLogging { + events("passed", "skipped", "failed") + } +} diff --git a/lib/src/main/java/org/openminimed/sake/package-info.java b/lib/src/main/java/org/openminimed/sake/package-info.java new file mode 100644 index 0000000..78d746c --- /dev/null +++ b/lib/src/main/java/org/openminimed/sake/package-info.java @@ -0,0 +1,7 @@ +/** + * Java port of the SAKE handshake protocol used by 700-series Medtronic pumps. + * + *

This package mirrors the public surface of the reference Python + * implementation at PythonSake.

+ */ +package org.openminimed.sake; diff --git a/lib/src/test/java/org/openminimed/sake/package-info.java b/lib/src/test/java/org/openminimed/sake/package-info.java new file mode 100644 index 0000000..df5a666 --- /dev/null +++ b/lib/src/test/java/org/openminimed/sake/package-info.java @@ -0,0 +1,4 @@ +/** + * Unit tests for the SAKE handshake state machine. + */ +package org.openminimed.sake; diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..74a9e59 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,3 @@ +rootProject.name = "JavaSake" + +include(":lib") From 7a4e9f5c558f5a914f468ba36152df8883448bf9 Mon Sep 17 00:00:00 2001 From: jlengelbrecht Date: Thu, 14 May 2026 00:06:20 -0400 Subject: [PATCH 02/10] Add DeviceType, StaticKeys and KeyDatabase parser Ports the three core data structures from pysake/keys.py and pysake/device_types.py with byte-for-byte round-trip parity against the three baked-in databases in pysake/constants.py (KEYDB_G4_CGM, KEYDB_PUMP_EXTRACTED, KEYDB_PUMP_HARDCODED). KeyDatabase.fromBytes() verifies the leading CRC32 and rejects malformed buffers. reverse() rebuilds a database with local and remote roles swapped and a freshly computed CRC. junit-platform-launcher added on the test runtime classpath because Gradle 9 no longer brings it in transitively via junit-jupiter-engine. --- gradle/libs.versions.toml | 1 + lib/build.gradle.kts | 1 + .../java/org/openminimed/sake/DeviceType.java | 47 +++++ .../org/openminimed/sake/KeyDatabase.java | 161 ++++++++++++++++++ .../java/org/openminimed/sake/StaticKeys.java | 103 +++++++++++ .../org/openminimed/sake/DeviceTypeTest.java | 40 +++++ .../test/java/org/openminimed/sake/Hex.java | 31 ++++ .../org/openminimed/sake/KeyDatabaseTest.java | 95 +++++++++++ 8 files changed, 479 insertions(+) create mode 100644 lib/src/main/java/org/openminimed/sake/DeviceType.java create mode 100644 lib/src/main/java/org/openminimed/sake/KeyDatabase.java create mode 100644 lib/src/main/java/org/openminimed/sake/StaticKeys.java create mode 100644 lib/src/test/java/org/openminimed/sake/DeviceTypeTest.java create mode 100644 lib/src/test/java/org/openminimed/sake/Hex.java create mode 100644 lib/src/test/java/org/openminimed/sake/KeyDatabaseTest.java diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 97510d2..cd8953d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,4 +5,5 @@ bouncycastle = "1.79" [libraries] junit-jupiter-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit" } junit-jupiter-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junit" } +junit-platform-launcher = { group = "org.junit.platform", name = "junit-platform-launcher" } bouncycastle = { group = "org.bouncycastle", name = "bcprov-jdk18on", version.ref = "bouncycastle" } diff --git a/lib/build.gradle.kts b/lib/build.gradle.kts index 889118f..c6ac9d4 100644 --- a/lib/build.gradle.kts +++ b/lib/build.gradle.kts @@ -20,6 +20,7 @@ dependencies { testImplementation(libs.junit.jupiter.api) testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) } tasks.test { diff --git a/lib/src/main/java/org/openminimed/sake/DeviceType.java b/lib/src/main/java/org/openminimed/sake/DeviceType.java new file mode 100644 index 0000000..59397f9 --- /dev/null +++ b/lib/src/main/java/org/openminimed/sake/DeviceType.java @@ -0,0 +1,47 @@ +package org.openminimed.sake; + +/** + * Type of device participating in a SAKE handshake. + * + *

The numeric values are wire-stable: they are serialized into the + * key-database header and into handshake messages.

+ */ +public enum DeviceType { + + INSULIN_PUMP(0x1), + GLUCOSE_SENSOR(0x2), + BLOOD_GLUCOSE_METER(0x3), + MOBILE_APPLICATION(0x4), + CARE_LINK_UPLOAD_APPLICATION(0x5), + FIRMWARE_UPDATE_APPLICATION(0x6), + DIAGNOSTIC_APPLICATION(0x7), + PRIMARY_DISPLAY(0x8); + + /** Alias: secondary display devices share the same wire value as mobile applications. */ + public static final DeviceType SECONDARY_DISPLAY = MOBILE_APPLICATION; + + private final int value; + + DeviceType(int value) { + this.value = value; + } + + /** @return the wire value (1 byte, unsigned). */ + public int value() { + return value; + } + + /** + * Resolve a device type from its wire value. + * + * @throws IllegalArgumentException if no device type matches. + */ + public static DeviceType fromValue(int value) { + for (DeviceType type : values()) { + if (type.value == value) { + return type; + } + } + throw new IllegalArgumentException("Unknown device type value: " + value); + } +} diff --git a/lib/src/main/java/org/openminimed/sake/KeyDatabase.java b/lib/src/main/java/org/openminimed/sake/KeyDatabase.java new file mode 100644 index 0000000..8a924c6 --- /dev/null +++ b/lib/src/main/java/org/openminimed/sake/KeyDatabase.java @@ -0,0 +1,161 @@ +package org.openminimed.sake; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.zip.CRC32; + +/** + * Database of static keys shared between one local device and any number of remote devices. + * + *

The on-wire layout is:

+ *
+ *   [ 4 B CRC32 big-endian over everything that follows ]
+ *   [ 1 B local device type ]
+ *   [ 1 B n = remote-device count ]
+ *   n * { [ 1 B remote device type ][ 80 B StaticKeys ] }
+ * 
+ * + *

Each entry following the header is therefore 81 bytes and the total serialized length is + * {@code 6 + 81 * n} bytes.

+ */ +public final class KeyDatabase { + + private static final int CRC_SIZE = 4; + private static final int HEADER_SIZE = 6; + private static final int ENTRY_SIZE = 1 + StaticKeys.SERIALIZED_SIZE; + + private final DeviceType localDeviceType; + private final Map remoteDevices; + private final byte[] crc; + + public KeyDatabase(DeviceType localDeviceType, + Map remoteDevices, + byte[] crc) { + this.localDeviceType = Objects.requireNonNull(localDeviceType, "localDeviceType"); + Objects.requireNonNull(remoteDevices, "remoteDevices"); + Objects.requireNonNull(crc, "crc"); + if (crc.length != CRC_SIZE) { + throw new IllegalArgumentException("crc must be " + CRC_SIZE + " bytes"); + } + this.remoteDevices = Collections.unmodifiableMap(new LinkedHashMap<>(remoteDevices)); + this.crc = crc.clone(); + } + + /** + * Parse a key database from its serialized form. + * + * @throws IllegalArgumentException if the buffer is malformed or the CRC does not match. + */ + public static KeyDatabase fromBytes(byte[] data) { + Objects.requireNonNull(data, "data"); + if (data.length < HEADER_SIZE) { + throw new IllegalArgumentException("Buffer is shorter than the database header"); + } + + byte[] storedCrc = new byte[CRC_SIZE]; + System.arraycopy(data, 0, storedCrc, 0, CRC_SIZE); + + byte[] payload = new byte[data.length - CRC_SIZE]; + System.arraycopy(data, CRC_SIZE, payload, 0, payload.length); + + byte[] computedCrc = computeCrc(payload); + if (!java.util.Arrays.equals(storedCrc, computedCrc)) { + throw new IllegalArgumentException("CRC mismatch: stored=" + + hex(storedCrc) + " computed=" + hex(computedCrc)); + } + + DeviceType localDeviceType = DeviceType.fromValue(payload[0] & 0xFF); + int n = payload[1] & 0xFF; + if (payload.length != 2 + ENTRY_SIZE * n) { + throw new IllegalArgumentException( + "Invalid database length for n=" + n + ": " + payload.length); + } + + Map remotes = new LinkedHashMap<>(); + for (int i = 0; i < n; i++) { + int base = 2 + i * ENTRY_SIZE; + DeviceType remoteType = DeviceType.fromValue(payload[base] & 0xFF); + byte[] keys = new byte[StaticKeys.SERIALIZED_SIZE]; + System.arraycopy(payload, base + 1, keys, 0, StaticKeys.SERIALIZED_SIZE); + remotes.put(remoteType, StaticKeys.fromBytes(keys)); + } + + return new KeyDatabase(localDeviceType, remotes, storedCrc); + } + + /** @return the serialized database with a freshly computed CRC. */ + public byte[] toBytes() { + byte[] payload = payloadBytes(); + byte[] crcBytes = computeCrc(payload); + byte[] out = new byte[CRC_SIZE + payload.length]; + System.arraycopy(crcBytes, 0, out, 0, CRC_SIZE); + System.arraycopy(payload, 0, out, CRC_SIZE, payload.length); + return out; + } + + /** + * Return a new database with the local/remote roles swapped. + * + *

Requires exactly one remote device.

+ * + * @throws IllegalStateException if this database does not contain exactly one remote device. + */ + public KeyDatabase reverse() { + if (remoteDevices.size() != 1) { + throw new IllegalStateException("reverse() requires exactly one remote device"); + } + Map.Entry only = remoteDevices.entrySet().iterator().next(); + Map reversed = new LinkedHashMap<>(); + reversed.put(localDeviceType, only.getValue()); + KeyDatabase placeholder = new KeyDatabase(only.getKey(), reversed, new byte[CRC_SIZE]); + byte[] newCrc = computeCrc(placeholder.payloadBytes()); + return new KeyDatabase(only.getKey(), reversed, newCrc); + } + + public DeviceType localDeviceType() { + return localDeviceType; + } + + public Map remoteDevices() { + return remoteDevices; + } + + public byte[] crc() { + return crc.clone(); + } + + private byte[] payloadBytes() { + byte[] out = new byte[2 + ENTRY_SIZE * remoteDevices.size()]; + out[0] = (byte) localDeviceType.value(); + out[1] = (byte) remoteDevices.size(); + int offset = 2; + for (Map.Entry entry : remoteDevices.entrySet()) { + out[offset] = (byte) entry.getKey().value(); + byte[] keys = entry.getValue().toBytes(); + System.arraycopy(keys, 0, out, offset + 1, keys.length); + offset += ENTRY_SIZE; + } + return out; + } + + private static byte[] computeCrc(byte[] payload) { + CRC32 crc32 = new CRC32(); + crc32.update(payload); + return ByteBuffer.allocate(CRC_SIZE) + .order(ByteOrder.BIG_ENDIAN) + .putInt((int) crc32.getValue()) + .array(); + } + + private static String hex(byte[] data) { + StringBuilder sb = new StringBuilder(data.length * 2); + for (byte b : data) { + sb.append(String.format("%02x", b & 0xFF)); + } + return sb.toString(); + } +} diff --git a/lib/src/main/java/org/openminimed/sake/StaticKeys.java b/lib/src/main/java/org/openminimed/sake/StaticKeys.java new file mode 100644 index 0000000..f6f99d1 --- /dev/null +++ b/lib/src/main/java/org/openminimed/sake/StaticKeys.java @@ -0,0 +1,103 @@ +package org.openminimed.sake; + +import java.util.Arrays; +import java.util.Objects; + +/** + * Five 16-byte keys shared between two specific devices, plus an opaque + * 16-byte handshake payload. + * + *

The on-wire form is the concatenation, in declaration order, of:

+ *
    + *
  1. {@code derivationKey}
  2. + *
  3. {@code handshakeAuthKey}
  4. + *
  5. {@code permitDecryptKey}
  6. + *
  7. {@code permitAuthKey}
  8. + *
  9. {@code handshakePayload}
  10. + *
+ */ +public final class StaticKeys { + + /** Size in bytes of each of the five fields. */ + public static final int FIELD_SIZE = 16; + + /** Total size in bytes of the serialized form. */ + public static final int SERIALIZED_SIZE = 5 * FIELD_SIZE; + + private final byte[] derivationKey; + private final byte[] handshakeAuthKey; + private final byte[] permitDecryptKey; + private final byte[] permitAuthKey; + private final byte[] handshakePayload; + + public StaticKeys(byte[] derivationKey, + byte[] handshakeAuthKey, + byte[] permitDecryptKey, + byte[] permitAuthKey, + byte[] handshakePayload) { + this.derivationKey = requireExactSize("derivationKey", derivationKey); + this.handshakeAuthKey = requireExactSize("handshakeAuthKey", handshakeAuthKey); + this.permitDecryptKey = requireExactSize("permitDecryptKey", permitDecryptKey); + this.permitAuthKey = requireExactSize("permitAuthKey", permitAuthKey); + this.handshakePayload = requireExactSize("handshakePayload", handshakePayload); + } + + /** + * Parse a {@link StaticKeys} from exactly {@value #SERIALIZED_SIZE} bytes. + * + * @throws IllegalArgumentException if the buffer is not exactly {@value #SERIALIZED_SIZE} bytes. + */ + public static StaticKeys fromBytes(byte[] data) { + Objects.requireNonNull(data, "data"); + if (data.length != SERIALIZED_SIZE) { + throw new IllegalArgumentException( + "StaticKeys requires " + SERIALIZED_SIZE + " bytes, got " + data.length); + } + return new StaticKeys( + Arrays.copyOfRange(data, 0 * FIELD_SIZE, 1 * FIELD_SIZE), + Arrays.copyOfRange(data, 1 * FIELD_SIZE, 2 * FIELD_SIZE), + Arrays.copyOfRange(data, 2 * FIELD_SIZE, 3 * FIELD_SIZE), + Arrays.copyOfRange(data, 3 * FIELD_SIZE, 4 * FIELD_SIZE), + Arrays.copyOfRange(data, 4 * FIELD_SIZE, 5 * FIELD_SIZE)); + } + + /** @return a new {@value #SERIALIZED_SIZE}-byte buffer containing the serialized form. */ + public byte[] toBytes() { + byte[] out = new byte[SERIALIZED_SIZE]; + System.arraycopy(derivationKey, 0, out, 0 * FIELD_SIZE, FIELD_SIZE); + System.arraycopy(handshakeAuthKey, 0, out, 1 * FIELD_SIZE, FIELD_SIZE); + System.arraycopy(permitDecryptKey, 0, out, 2 * FIELD_SIZE, FIELD_SIZE); + System.arraycopy(permitAuthKey, 0, out, 3 * FIELD_SIZE, FIELD_SIZE); + System.arraycopy(handshakePayload, 0, out, 4 * FIELD_SIZE, FIELD_SIZE); + return out; + } + + public byte[] derivationKey() { + return derivationKey.clone(); + } + + public byte[] handshakeAuthKey() { + return handshakeAuthKey.clone(); + } + + public byte[] permitDecryptKey() { + return permitDecryptKey.clone(); + } + + public byte[] permitAuthKey() { + return permitAuthKey.clone(); + } + + public byte[] handshakePayload() { + return handshakePayload.clone(); + } + + private static byte[] requireExactSize(String name, byte[] data) { + Objects.requireNonNull(data, name); + if (data.length != FIELD_SIZE) { + throw new IllegalArgumentException( + name + " must be " + FIELD_SIZE + " bytes, got " + data.length); + } + return data.clone(); + } +} diff --git a/lib/src/test/java/org/openminimed/sake/DeviceTypeTest.java b/lib/src/test/java/org/openminimed/sake/DeviceTypeTest.java new file mode 100644 index 0000000..c5d6978 --- /dev/null +++ b/lib/src/test/java/org/openminimed/sake/DeviceTypeTest.java @@ -0,0 +1,40 @@ +package org.openminimed.sake; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class DeviceTypeTest { + + @Test + void wireValuesAreStable() { + assertEquals(0x1, DeviceType.INSULIN_PUMP.value()); + assertEquals(0x2, DeviceType.GLUCOSE_SENSOR.value()); + assertEquals(0x3, DeviceType.BLOOD_GLUCOSE_METER.value()); + assertEquals(0x4, DeviceType.MOBILE_APPLICATION.value()); + assertEquals(0x5, DeviceType.CARE_LINK_UPLOAD_APPLICATION.value()); + assertEquals(0x6, DeviceType.FIRMWARE_UPDATE_APPLICATION.value()); + assertEquals(0x7, DeviceType.DIAGNOSTIC_APPLICATION.value()); + assertEquals(0x8, DeviceType.PRIMARY_DISPLAY.value()); + } + + @Test + void secondaryDisplayIsAliasForMobileApplication() { + assertSame(DeviceType.MOBILE_APPLICATION, DeviceType.SECONDARY_DISPLAY); + assertEquals(0x4, DeviceType.SECONDARY_DISPLAY.value()); + } + + @Test + void fromValueResolvesEachWireValue() { + for (DeviceType type : DeviceType.values()) { + assertSame(type, DeviceType.fromValue(type.value())); + } + } + + @Test + void fromValueRejectsUnknownValue() { + assertThrows(IllegalArgumentException.class, () -> DeviceType.fromValue(0xFF)); + } +} diff --git a/lib/src/test/java/org/openminimed/sake/Hex.java b/lib/src/test/java/org/openminimed/sake/Hex.java new file mode 100644 index 0000000..0d1fa60 --- /dev/null +++ b/lib/src/test/java/org/openminimed/sake/Hex.java @@ -0,0 +1,31 @@ +package org.openminimed.sake; + +final class Hex { + + private Hex() { + } + + static byte[] decode(String hex) { + if ((hex.length() & 1) != 0) { + throw new IllegalArgumentException("Hex string has odd length: " + hex.length()); + } + byte[] out = new byte[hex.length() / 2]; + for (int i = 0; i < out.length; i++) { + int high = Character.digit(hex.charAt(2 * i), 16); + int low = Character.digit(hex.charAt(2 * i + 1), 16); + if (high < 0 || low < 0) { + throw new IllegalArgumentException("Invalid hex character at index " + (2 * i)); + } + out[i] = (byte) ((high << 4) | low); + } + return out; + } + + static String encode(byte[] data) { + StringBuilder sb = new StringBuilder(data.length * 2); + for (byte b : data) { + sb.append(String.format("%02x", b & 0xFF)); + } + return sb.toString(); + } +} diff --git a/lib/src/test/java/org/openminimed/sake/KeyDatabaseTest.java b/lib/src/test/java/org/openminimed/sake/KeyDatabaseTest.java new file mode 100644 index 0000000..79411c9 --- /dev/null +++ b/lib/src/test/java/org/openminimed/sake/KeyDatabaseTest.java @@ -0,0 +1,95 @@ +package org.openminimed.sake; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class KeyDatabaseTest { + + /** + * The three baked-in key databases from the reference Python implementation + * (pysake/constants.py). They are the canonical round-trip vectors for this + * parser: any change to the serialization must keep these byte-identical. + */ + private static final String HEX_G4_CGM = + "5fe5928308010230f0b50df613f2e429c8c5e8713854add1a69b837235a3e974" + + "304d8055ccb397838b90823c73236d6a83dcc9db3a2a939ff16145ca4169ef93" + + "a7fa39b20962b05e57413bff8b3d61fce0dfef2c43b326"; + + private static final String HEX_PUMP_EXTRACTED = + "f75995e70401011bc1bf7cbf36fa1e2367d795ff09211903da6afbe986b650f1" + + "4179c0e6852e0ce393781078ffc6f51919e2eaefbde69b8eca21e41ab59b881a" + + "0bea0286ea91dc7582a86a714e1737f558f0d66dc1895c"; + + private static final String HEX_PUMP_HARDCODED = + "c2cdfdd1040101fce36ed66ef21def3b0763975494b239038ebe8606f79a9bf0" + + "0d9f11b6db04c7c0434787cbf00d5476289c22288e2105ae40e01391837f9476" + + "fa5003895c5a1afe35662a2a6211826af016eebe30e4ba"; + + @Test + void parsesG4Cgm() { + KeyDatabase db = KeyDatabase.fromBytes(Hex.decode(HEX_G4_CGM)); + assertEquals(DeviceType.PRIMARY_DISPLAY, db.localDeviceType()); + assertEquals(1, db.remoteDevices().size()); + assertNotNull(db.remoteDevices().get(DeviceType.GLUCOSE_SENSOR)); + } + + @Test + void parsesPumpExtracted() { + KeyDatabase db = KeyDatabase.fromBytes(Hex.decode(HEX_PUMP_EXTRACTED)); + assertEquals(DeviceType.MOBILE_APPLICATION, db.localDeviceType()); + assertEquals(1, db.remoteDevices().size()); + assertNotNull(db.remoteDevices().get(DeviceType.INSULIN_PUMP)); + } + + @Test + void parsesPumpHardcoded() { + KeyDatabase db = KeyDatabase.fromBytes(Hex.decode(HEX_PUMP_HARDCODED)); + assertEquals(DeviceType.MOBILE_APPLICATION, db.localDeviceType()); + assertEquals(1, db.remoteDevices().size()); + assertNotNull(db.remoteDevices().get(DeviceType.INSULIN_PUMP)); + } + + @Test + void roundTripIsByteIdentical() { + for (String hex : new String[]{HEX_G4_CGM, HEX_PUMP_EXTRACTED, HEX_PUMP_HARDCODED}) { + byte[] original = Hex.decode(hex); + byte[] roundTripped = KeyDatabase.fromBytes(original).toBytes(); + assertArrayEquals(original, roundTripped, + "Round trip differed for " + hex.substring(0, 16) + "..."); + } + } + + @Test + void rejectsCrcMismatch() { + byte[] corrupt = Hex.decode(HEX_PUMP_EXTRACTED); + corrupt[0] ^= (byte) 0x01; + assertThrows(IllegalArgumentException.class, () -> KeyDatabase.fromBytes(corrupt)); + } + + @Test + void rejectsTruncatedBuffer() { + byte[] truncated = new byte[]{0x00, 0x00, 0x00, 0x00, 0x04}; + assertThrows(IllegalArgumentException.class, () -> KeyDatabase.fromBytes(truncated)); + } + + @Test + void reverseProducesValidDatabase() { + for (String hex : new String[]{HEX_G4_CGM, HEX_PUMP_EXTRACTED, HEX_PUMP_HARDCODED}) { + KeyDatabase original = KeyDatabase.fromBytes(Hex.decode(hex)); + KeyDatabase reversed = original.reverse(); + assertEquals( + original.remoteDevices().keySet().iterator().next(), + reversed.localDeviceType()); + assertEquals(1, reversed.remoteDevices().size()); + assertNotNull(reversed.remoteDevices().get(original.localDeviceType())); + + byte[] reversedBytes = reversed.toBytes(); + KeyDatabase reparsed = KeyDatabase.fromBytes(reversedBytes); + assertEquals(reversed.localDeviceType(), reparsed.localDeviceType()); + } + } +} From 5c4c02b06cc892ea99bf597b6b5d3164b3719282 Mon Sep 17 00:00:00 2001 From: jlengelbrecht Date: Thu, 14 May 2026 00:25:10 -0400 Subject: [PATCH 03/10] Add AES primitive wrappers with NIST/RFC known-answer tests Three thin helpers in org.openminimed.sake.crypto: AesCmac stateful AES-128-CMAC with configurable truncation (mac_len 4 and 8 are both used by the handshake) AesEcb single-block AES-128 ECB encrypt/decrypt AesCtr streaming AES-128 CTR with caller-supplied 16-byte IV AES-ECB and AES-CTR use the JDK's JCE provider. AES-CMAC uses BouncyCastle since the JDK does not ship a CMAC implementation. Validated against the standard published vectors: AES-CMAC RFC 4493 Appendix examples 1-4 (empty, 16, 40, 64 bytes) AES-ECB NIST SP 800-38A F.1.1 blocks 1 and 2 AES-CTR NIST SP 800-38A F.5.1 full 64-byte stream Hex test helper is now public so subpackage tests can reuse it. --- .../org/openminimed/sake/crypto/AesCmac.java | 71 ++++++++++++ .../org/openminimed/sake/crypto/AesCtr.java | 46 ++++++++ .../org/openminimed/sake/crypto/AesEcb.java | 47 ++++++++ .../openminimed/sake/crypto/package-info.java | 7 ++ .../test/java/org/openminimed/sake/Hex.java | 6 +- .../openminimed/sake/crypto/AesCmacTest.java | 103 ++++++++++++++++++ .../openminimed/sake/crypto/AesCtrTest.java | 60 ++++++++++ .../openminimed/sake/crypto/AesEcbTest.java | 49 +++++++++ 8 files changed, 386 insertions(+), 3 deletions(-) create mode 100644 lib/src/main/java/org/openminimed/sake/crypto/AesCmac.java create mode 100644 lib/src/main/java/org/openminimed/sake/crypto/AesCtr.java create mode 100644 lib/src/main/java/org/openminimed/sake/crypto/AesEcb.java create mode 100644 lib/src/main/java/org/openminimed/sake/crypto/package-info.java create mode 100644 lib/src/test/java/org/openminimed/sake/crypto/AesCmacTest.java create mode 100644 lib/src/test/java/org/openminimed/sake/crypto/AesCtrTest.java create mode 100644 lib/src/test/java/org/openminimed/sake/crypto/AesEcbTest.java diff --git a/lib/src/main/java/org/openminimed/sake/crypto/AesCmac.java b/lib/src/main/java/org/openminimed/sake/crypto/AesCmac.java new file mode 100644 index 0000000..d9c2edc --- /dev/null +++ b/lib/src/main/java/org/openminimed/sake/crypto/AesCmac.java @@ -0,0 +1,71 @@ +package org.openminimed.sake.crypto; + +import org.bouncycastle.crypto.engines.AESEngine; +import org.bouncycastle.crypto.macs.CMac; +import org.bouncycastle.crypto.params.KeyParameter; + +import java.util.Arrays; +import java.util.Objects; + +/** + * Stateful AES-CMAC over a 16-byte AES-128 key with a configurable truncation length. + * + *

The handshake uses both {@code macLen=8} (handshake CMAC chain) and + * {@code macLen=4} (permit auth, SeqCrypt trailer prefix). BouncyCastle always + * produces a 16-byte tag; we truncate to {@code macLen} bytes to match the + * PyCryptodome {@code mac_len} parameter.

+ */ +public final class AesCmac { + + /** AES block size and full MAC length in bytes. */ + public static final int BLOCK_SIZE = 16; + + private final CMac mac; + private final int macLen; + + public AesCmac(byte[] key, int macLen) { + Objects.requireNonNull(key, "key"); + if (key.length != BLOCK_SIZE) { + throw new IllegalArgumentException("AES-128 key must be " + BLOCK_SIZE + " bytes"); + } + if (macLen < 1 || macLen > BLOCK_SIZE) { + throw new IllegalArgumentException("macLen must be 1.." + BLOCK_SIZE); + } + this.mac = new CMac(AESEngine.newInstance()); + this.mac.init(new KeyParameter(key)); + this.macLen = macLen; + } + + public AesCmac update(byte[] data) { + Objects.requireNonNull(data, "data"); + mac.update(data, 0, data.length); + return this; + } + + public byte[] digest() { + byte[] full = new byte[mac.getMacSize()]; + mac.doFinal(full, 0); + if (macLen == full.length) { + return full; + } + return Arrays.copyOf(full, macLen); + } + + /** + * Constant-time comparison against an expected tag. + * + * @return true if the computed digest matches {@code expected}, byte-for-byte. + */ + public boolean verify(byte[] expected) { + Objects.requireNonNull(expected, "expected"); + byte[] actual = digest(); + if (actual.length != expected.length) { + return false; + } + int diff = 0; + for (int i = 0; i < actual.length; i++) { + diff |= actual[i] ^ expected[i]; + } + return diff == 0; + } +} diff --git a/lib/src/main/java/org/openminimed/sake/crypto/AesCtr.java b/lib/src/main/java/org/openminimed/sake/crypto/AesCtr.java new file mode 100644 index 0000000..9d45f22 --- /dev/null +++ b/lib/src/main/java/org/openminimed/sake/crypto/AesCtr.java @@ -0,0 +1,46 @@ +package org.openminimed.sake.crypto; + +import javax.crypto.Cipher; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.security.GeneralSecurityException; +import java.util.Objects; + +/** + * AES-128 CTR stream encryption. + * + *

The 16-byte IV is treated as the initial 128-bit counter and incremented + * by one per block. Callers are responsible for assembling the IV such that + * the counter region does not wrap into the nonce region.

+ * + *

CTR is symmetric so the same method is used to encrypt and decrypt.

+ */ +public final class AesCtr { + + /** AES block size and IV length in bytes. */ + public static final int BLOCK_SIZE = 16; + + private AesCtr() { + } + + public static byte[] crypt(byte[] key, byte[] iv, byte[] data) { + Objects.requireNonNull(key, "key"); + Objects.requireNonNull(iv, "iv"); + Objects.requireNonNull(data, "data"); + if (key.length != BLOCK_SIZE) { + throw new IllegalArgumentException("AES-128 key must be " + BLOCK_SIZE + " bytes"); + } + if (iv.length != BLOCK_SIZE) { + throw new IllegalArgumentException("IV must be " + BLOCK_SIZE + " bytes"); + } + try { + Cipher cipher = Cipher.getInstance("AES/CTR/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, + new SecretKeySpec(key, "AES"), + new IvParameterSpec(iv)); + return cipher.doFinal(data); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("AES/CTR/NoPadding unavailable", e); + } + } +} diff --git a/lib/src/main/java/org/openminimed/sake/crypto/AesEcb.java b/lib/src/main/java/org/openminimed/sake/crypto/AesEcb.java new file mode 100644 index 0000000..4c29ac3 --- /dev/null +++ b/lib/src/main/java/org/openminimed/sake/crypto/AesEcb.java @@ -0,0 +1,47 @@ +package org.openminimed.sake.crypto; + +import javax.crypto.Cipher; +import javax.crypto.spec.SecretKeySpec; +import java.security.GeneralSecurityException; +import java.util.Objects; + +/** + * Single-block AES-128 ECB encrypt / decrypt. + * + *

Used by the handshake exclusively on 16-byte blocks; this class does not + * accept anything else.

+ */ +public final class AesEcb { + + /** AES block size in bytes. */ + public static final int BLOCK_SIZE = 16; + + private AesEcb() { + } + + public static byte[] encryptBlock(byte[] key, byte[] block) { + return process(key, block, Cipher.ENCRYPT_MODE); + } + + public static byte[] decryptBlock(byte[] key, byte[] block) { + return process(key, block, Cipher.DECRYPT_MODE); + } + + private static byte[] process(byte[] key, byte[] block, int mode) { + Objects.requireNonNull(key, "key"); + Objects.requireNonNull(block, "block"); + if (key.length != BLOCK_SIZE) { + throw new IllegalArgumentException("AES-128 key must be " + BLOCK_SIZE + " bytes"); + } + if (block.length != BLOCK_SIZE) { + throw new IllegalArgumentException("Block must be " + BLOCK_SIZE + " bytes"); + } + try { + Cipher cipher = Cipher.getInstance("AES/ECB/NoPadding"); + cipher.init(mode, new SecretKeySpec(key, "AES")); + return cipher.doFinal(block); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("AES/ECB/NoPadding unavailable", e); + } + } +} diff --git a/lib/src/main/java/org/openminimed/sake/crypto/package-info.java b/lib/src/main/java/org/openminimed/sake/crypto/package-info.java new file mode 100644 index 0000000..9366173 --- /dev/null +++ b/lib/src/main/java/org/openminimed/sake/crypto/package-info.java @@ -0,0 +1,7 @@ +/** + * Thin AES primitive wrappers used by the SAKE handshake. + * + *

AES-ECB and AES-CTR are served by the JDK's JCE provider. AES-CMAC is + * implemented via BouncyCastle as the JDK does not ship it.

+ */ +package org.openminimed.sake.crypto; diff --git a/lib/src/test/java/org/openminimed/sake/Hex.java b/lib/src/test/java/org/openminimed/sake/Hex.java index 0d1fa60..523db1b 100644 --- a/lib/src/test/java/org/openminimed/sake/Hex.java +++ b/lib/src/test/java/org/openminimed/sake/Hex.java @@ -1,11 +1,11 @@ package org.openminimed.sake; -final class Hex { +public final class Hex { private Hex() { } - static byte[] decode(String hex) { + public static byte[] decode(String hex) { if ((hex.length() & 1) != 0) { throw new IllegalArgumentException("Hex string has odd length: " + hex.length()); } @@ -21,7 +21,7 @@ static byte[] decode(String hex) { return out; } - static String encode(byte[] data) { + public static String encode(byte[] data) { StringBuilder sb = new StringBuilder(data.length * 2); for (byte b : data) { sb.append(String.format("%02x", b & 0xFF)); diff --git a/lib/src/test/java/org/openminimed/sake/crypto/AesCmacTest.java b/lib/src/test/java/org/openminimed/sake/crypto/AesCmacTest.java new file mode 100644 index 0000000..5c6dc46 --- /dev/null +++ b/lib/src/test/java/org/openminimed/sake/crypto/AesCmacTest.java @@ -0,0 +1,103 @@ +package org.openminimed.sake.crypto; + +import org.junit.jupiter.api.Test; +import org.openminimed.sake.Hex; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Known-answer tests from RFC 4493 (AES-CMAC) Appendix. + */ +class AesCmacTest { + + private static final byte[] KEY = Hex.decode("2b7e151628aed2a6abf7158809cf4f3c"); + + @Test + void rfc4493ExampleEmpty() { + byte[] expected = Hex.decode("bb1d6929e95937287fa37d129b756746"); + AesCmac cmac = new AesCmac(KEY, 16); + cmac.update(new byte[0]); + assertArrayEquals(expected, cmac.digest()); + } + + @Test + void rfc4493ExampleOneBlock() { + byte[] msg = Hex.decode("6bc1bee22e409f96e93d7e117393172a"); + byte[] expected = Hex.decode("070a16b46b4d4144f79bdd9dd04a287c"); + AesCmac cmac = new AesCmac(KEY, 16); + cmac.update(msg); + assertArrayEquals(expected, cmac.digest()); + } + + @Test + void rfc4493ExampleFortyBytes() { + byte[] msg = Hex.decode( + "6bc1bee22e409f96e93d7e117393172a" + + "ae2d8a571e03ac9c9eb76fac45af8e51" + + "30c81c46a35ce411"); + byte[] expected = Hex.decode("dfa66747de9ae63030ca32611497c827"); + AesCmac cmac = new AesCmac(KEY, 16); + cmac.update(msg); + assertArrayEquals(expected, cmac.digest()); + } + + @Test + void rfc4493ExampleSixtyFourBytes() { + byte[] msg = Hex.decode( + "6bc1bee22e409f96e93d7e117393172a" + + "ae2d8a571e03ac9c9eb76fac45af8e51" + + "30c81c46a35ce411e5fbc1191a0a52ef" + + "f69f2445df4f9b17ad2b417be66c3710"); + byte[] expected = Hex.decode("51f0bebf7e3b9d92fc49741779363cfe"); + AesCmac cmac = new AesCmac(KEY, 16); + cmac.update(msg); + assertArrayEquals(expected, cmac.digest()); + } + + @Test + void truncationToFourBytes() { + byte[] msg = Hex.decode("6bc1bee22e409f96e93d7e117393172a"); + byte[] full = Hex.decode("070a16b46b4d4144f79bdd9dd04a287c"); + AesCmac cmac = new AesCmac(KEY, 4); + cmac.update(msg); + byte[] truncated = cmac.digest(); + assertEquals(4, truncated.length); + for (int i = 0; i < 4; i++) { + assertEquals(full[i], truncated[i]); + } + } + + @Test + void truncationToEightBytes() { + byte[] msg = Hex.decode("6bc1bee22e409f96e93d7e117393172a"); + byte[] full = Hex.decode("070a16b46b4d4144f79bdd9dd04a287c"); + AesCmac cmac = new AesCmac(KEY, 8); + cmac.update(msg); + byte[] truncated = cmac.digest(); + assertEquals(8, truncated.length); + for (int i = 0; i < 8; i++) { + assertEquals(full[i], truncated[i]); + } + } + + @Test + void verifyAcceptsMatchingTag() { + byte[] msg = Hex.decode("6bc1bee22e409f96e93d7e117393172a"); + byte[] tag = Hex.decode("070a16b46b4d4144"); + AesCmac cmac = new AesCmac(KEY, 8); + cmac.update(msg); + assertTrue(cmac.verify(tag)); + } + + @Test + void verifyRejectsTamperedTag() { + byte[] msg = Hex.decode("6bc1bee22e409f96e93d7e117393172a"); + byte[] tag = Hex.decode("070a16b46b4d4145"); + AesCmac cmac = new AesCmac(KEY, 8); + cmac.update(msg); + assertFalse(cmac.verify(tag)); + } +} diff --git a/lib/src/test/java/org/openminimed/sake/crypto/AesCtrTest.java b/lib/src/test/java/org/openminimed/sake/crypto/AesCtrTest.java new file mode 100644 index 0000000..abfaa21 --- /dev/null +++ b/lib/src/test/java/org/openminimed/sake/crypto/AesCtrTest.java @@ -0,0 +1,60 @@ +package org.openminimed.sake.crypto; + +import org.junit.jupiter.api.Test; +import org.openminimed.sake.Hex; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Known-answer tests from NIST SP 800-38A Appendix F.5 (AES-128 CTR). + */ +class AesCtrTest { + + private static final byte[] KEY = Hex.decode("2b7e151628aed2a6abf7158809cf4f3c"); + private static final byte[] INITIAL_COUNTER = + Hex.decode("f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff"); + + @Test + void encryptsNistMultiBlockStream() { + byte[] plain = Hex.decode( + "6bc1bee22e409f96e93d7e117393172a" + + "ae2d8a571e03ac9c9eb76fac45af8e51" + + "30c81c46a35ce411e5fbc1191a0a52ef" + + "f69f2445df4f9b17ad2b417be66c3710"); + byte[] expected = Hex.decode( + "874d6191b620e3261bef6864990db6ce" + + "9806f66b7970fdff8617187bb9fffdff" + + "5ae4df3edbd5d35e5b4f09020db03eab" + + "1e031dda2fbe03d1792170a0f3009cee"); + assertArrayEquals(expected, AesCtr.crypt(KEY, INITIAL_COUNTER, plain)); + } + + @Test + void encryptDecryptIsSymmetric() { + byte[] plain = Hex.decode("6bc1bee22e409f96e93d7e117393172a"); + byte[] cipher = AesCtr.crypt(KEY, INITIAL_COUNTER, plain); + byte[] recovered = AesCtr.crypt(KEY, INITIAL_COUNTER, cipher); + assertArrayEquals(plain, recovered); + } + + @Test + void encryptsPartialBlock() { + byte[] plain = Hex.decode("6bc1bee22e409f96e93d7e1173"); + byte[] cipher = AesCtr.crypt(KEY, INITIAL_COUNTER, plain); + byte[] recovered = AesCtr.crypt(KEY, INITIAL_COUNTER, cipher); + assertArrayEquals(plain, recovered); + } + + @Test + void rejectsWrongIvLength() { + assertThrows(IllegalArgumentException.class, + () -> AesCtr.crypt(KEY, new byte[15], new byte[0])); + } + + @Test + void rejectsWrongKeyLength() { + assertThrows(IllegalArgumentException.class, + () -> AesCtr.crypt(new byte[24], INITIAL_COUNTER, new byte[0])); + } +} diff --git a/lib/src/test/java/org/openminimed/sake/crypto/AesEcbTest.java b/lib/src/test/java/org/openminimed/sake/crypto/AesEcbTest.java new file mode 100644 index 0000000..0e3f7e5 --- /dev/null +++ b/lib/src/test/java/org/openminimed/sake/crypto/AesEcbTest.java @@ -0,0 +1,49 @@ +package org.openminimed.sake.crypto; + +import org.junit.jupiter.api.Test; +import org.openminimed.sake.Hex; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Known-answer tests from NIST SP 800-38A Appendix F.1 (AES-128 ECB). + */ +class AesEcbTest { + + private static final byte[] KEY = Hex.decode("2b7e151628aed2a6abf7158809cf4f3c"); + + @Test + void encryptsFirstNistBlock() { + byte[] plain = Hex.decode("6bc1bee22e409f96e93d7e117393172a"); + byte[] expected = Hex.decode("3ad77bb40d7a3660a89ecaf32466ef97"); + assertArrayEquals(expected, AesEcb.encryptBlock(KEY, plain)); + } + + @Test + void encryptsSecondNistBlock() { + byte[] plain = Hex.decode("ae2d8a571e03ac9c9eb76fac45af8e51"); + byte[] expected = Hex.decode("f5d3d58503b9699de785895a96fdbaaf"); + assertArrayEquals(expected, AesEcb.encryptBlock(KEY, plain)); + } + + @Test + void decryptsFirstNistBlock() { + byte[] cipher = Hex.decode("3ad77bb40d7a3660a89ecaf32466ef97"); + byte[] expected = Hex.decode("6bc1bee22e409f96e93d7e117393172a"); + assertArrayEquals(expected, AesEcb.decryptBlock(KEY, cipher)); + } + + @Test + void rejectsWrongKeyLength() { + byte[] plain = new byte[16]; + assertThrows(IllegalArgumentException.class, + () -> AesEcb.encryptBlock(new byte[15], plain)); + } + + @Test + void rejectsWrongBlockLength() { + assertThrows(IllegalArgumentException.class, + () -> AesEcb.encryptBlock(KEY, new byte[15])); + } +} From 0a743b07864ef161bd9e36168a71e11734c3ae6f Mon Sep 17 00:00:00 2001 From: jlengelbrecht Date: Thu, 14 May 2026 01:04:20 -0400 Subject: [PATCH 04/10] Add SeqCrypt: AES-CTR stream with CMAC trailer Port of pysake/seqcrypt.py. Each direction of a SAKE session uses one instance of this class; encryption produces a 3-byte trailer of the form [(seq >> 1) & 0xFF, mac[0], mac[1]] where mac is the first two bytes of CMAC4(nonce.padTo16 || ciphertext). The decrypt path reconstructs the full 32-bit sequence from the locally tracked rxSeq and the trailer byte using an 8-bit delta, matching the Python wrap-around semantics. The brute-force seq fallback present in the Python implementation is intentionally omitted: it is debug-only and not used on the production path. Parity vectors captured from a Python harness driving pysake.SeqCrypt with fixed key, nonce and seq. The Java implementation produces byte-identical ciphertexts at both seq=0 and seq=1 (the two starting seeds used by Session._create_crypts) and decrypts them back to the original plaintext with the expected rxSeq advancement. MacFailureException is a checked exception so callers cannot quietly ignore a verification failure. --- .../openminimed/sake/MacFailureException.java | 13 ++ .../java/org/openminimed/sake/SeqCrypt.java | 144 ++++++++++++++++++ .../org/openminimed/sake/SeqCryptTest.java | 107 +++++++++++++ 3 files changed, 264 insertions(+) create mode 100644 lib/src/main/java/org/openminimed/sake/MacFailureException.java create mode 100644 lib/src/main/java/org/openminimed/sake/SeqCrypt.java create mode 100644 lib/src/test/java/org/openminimed/sake/SeqCryptTest.java diff --git a/lib/src/main/java/org/openminimed/sake/MacFailureException.java b/lib/src/main/java/org/openminimed/sake/MacFailureException.java new file mode 100644 index 0000000..85560f5 --- /dev/null +++ b/lib/src/main/java/org/openminimed/sake/MacFailureException.java @@ -0,0 +1,13 @@ +package org.openminimed.sake; + +/** + * Thrown when a CMAC trailer does not match the computed value during decryption. + */ +public class MacFailureException extends Exception { + + private static final long serialVersionUID = 1L; + + public MacFailureException(String message) { + super(message); + } +} diff --git a/lib/src/main/java/org/openminimed/sake/SeqCrypt.java b/lib/src/main/java/org/openminimed/sake/SeqCrypt.java new file mode 100644 index 0000000..b1521b4 --- /dev/null +++ b/lib/src/main/java/org/openminimed/sake/SeqCrypt.java @@ -0,0 +1,144 @@ +package org.openminimed.sake; + +import org.openminimed.sake.crypto.AesCmac; +import org.openminimed.sake.crypto.AesCtr; + +import java.util.Arrays; +import java.util.Objects; + +/** + * Sequence-numbered AES-CTR stream cipher with a CMAC trailer. + * + *

Each direction of the SAKE session is one {@code SeqCrypt} instance with + * its own sequence counter. The encrypted form of a message is + * {@code ciphertext || trailer} where {@code trailer} is three bytes:

+ * + *
+ *   [ (seq >> 1) & 0xFF ][ CMAC4(nonce.padTo16 || ciphertext)[0..2] ]
+ * 
+ * + *

The receiver reconstructs the full 32-bit sequence from its local + * {@code rxSeq} and the 1-byte field in the trailer, tolerating an 8-bit + * wrap-around.

+ */ +public final class SeqCrypt { + + private static final int TRAILER_SIZE = 3; + private static final int SEQ_PREFIX_SIZE = 5; + private static final int NONCE_SIZE = 8; + private static final int MAC_SIZE = 4; + private static final int IV_SIZE = 16; + + private final byte[] key; + private final byte[] nonce; + private long txSeq; + private long rxSeq; + + public SeqCrypt(byte[] key, byte[] nonce, long initialSeq) { + Objects.requireNonNull(key, "key"); + Objects.requireNonNull(nonce, "nonce"); + if (key.length != IV_SIZE) { + throw new IllegalArgumentException("key must be " + IV_SIZE + " bytes"); + } + if (nonce.length != NONCE_SIZE) { + throw new IllegalArgumentException("nonce must be " + NONCE_SIZE + " bytes"); + } + this.key = key.clone(); + this.nonce = nonce.clone(); + this.txSeq = initialSeq; + this.rxSeq = initialSeq; + } + + public byte[] encrypt(byte[] plaintext) { + Objects.requireNonNull(plaintext, "plaintext"); + long seq = txSeq; + byte[] iv = buildIv(seq); + byte[] ciphertext = AesCtr.crypt(key, iv, plaintext); + byte[] tagPrefix = computeTagPrefix(seq, ciphertext); + + byte[] out = new byte[ciphertext.length + TRAILER_SIZE]; + System.arraycopy(ciphertext, 0, out, 0, ciphertext.length); + out[ciphertext.length] = (byte) ((seq >>> 1) & 0xFF); + out[ciphertext.length + 1] = tagPrefix[0]; + out[ciphertext.length + 2] = tagPrefix[1]; + + txSeq = seq + 2; + return out; + } + + /** + * Decrypt and authenticate a message. + * + * @throws IllegalArgumentException if the buffer is shorter than the trailer. + * @throws MacFailureException if the trailer MAC does not match the computed value. + */ + public byte[] decrypt(byte[] message) throws MacFailureException { + Objects.requireNonNull(message, "message"); + if (message.length < TRAILER_SIZE) { + throw new IllegalArgumentException("Message shorter than trailer"); + } + + int seqByte = message[message.length - TRAILER_SIZE] & 0xFF; + int delta = (seqByte - (int) ((rxSeq >>> 1) & 0xFF)) & 0xFF; + long seq = rxSeq + 2L * delta; + + int ciphertextLen = message.length - TRAILER_SIZE; + byte[] ciphertext = Arrays.copyOfRange(message, 0, ciphertextLen); + byte[] tagPrefix = computeTagPrefix(seq, ciphertext); + + if ((tagPrefix[0] != message[ciphertextLen + 1]) + || (tagPrefix[1] != message[ciphertextLen + 2])) { + throw new MacFailureException( + "MAC verification failed at seq=" + seq); + } + + byte[] iv = buildIv(seq); + byte[] plaintext = AesCtr.crypt(key, iv, ciphertext); + rxSeq = seq + 2; + return plaintext; + } + + public long getTxSeq() { + return txSeq; + } + + public long getRxSeq() { + return rxSeq; + } + + public void setTxSeq(long value) { + this.txSeq = value; + } + + public void setRxSeq(long value) { + this.rxSeq = value; + } + + private byte[] buildIv(long seq) { + byte[] iv = new byte[IV_SIZE]; + iv[0] = (byte) ((seq >>> 32) & 0xFF); + iv[1] = (byte) ((seq >>> 24) & 0xFF); + iv[2] = (byte) ((seq >>> 16) & 0xFF); + iv[3] = (byte) ((seq >>> 8) & 0xFF); + iv[4] = (byte) (seq & 0xFF); + System.arraycopy(nonce, 0, iv, SEQ_PREFIX_SIZE, NONCE_SIZE); + // Trailing 3 bytes remain zero (counter region for AES-CTR). + return iv; + } + + private byte[] computeTagPrefix(long seq, byte[] ciphertext) { + byte[] cmacInput = new byte[IV_SIZE + ciphertext.length]; + cmacInput[0] = (byte) ((seq >>> 32) & 0xFF); + cmacInput[1] = (byte) ((seq >>> 24) & 0xFF); + cmacInput[2] = (byte) ((seq >>> 16) & 0xFF); + cmacInput[3] = (byte) ((seq >>> 8) & 0xFF); + cmacInput[4] = (byte) (seq & 0xFF); + System.arraycopy(nonce, 0, cmacInput, SEQ_PREFIX_SIZE, NONCE_SIZE); + // Bytes 13..15 are the zero-padding to 16 (`ljust` in the Python). + System.arraycopy(ciphertext, 0, cmacInput, IV_SIZE, ciphertext.length); + + AesCmac cmac = new AesCmac(key, MAC_SIZE); + cmac.update(cmacInput); + return cmac.digest(); + } +} diff --git a/lib/src/test/java/org/openminimed/sake/SeqCryptTest.java b/lib/src/test/java/org/openminimed/sake/SeqCryptTest.java new file mode 100644 index 0000000..6b045bd --- /dev/null +++ b/lib/src/test/java/org/openminimed/sake/SeqCryptTest.java @@ -0,0 +1,107 @@ +package org.openminimed.sake; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Parity tests against the reference Python implementation + * ({@code pysake/seqcrypt.py}). + * + *

The captured ciphertexts below were generated by running the Python + * SeqCrypt with the same key, nonce and starting sequence values as the + * test setup here. Any divergence between Java and Python output for the + * same inputs would cause an assertion to fail.

+ */ +class SeqCryptTest { + + private static final byte[] KEY = Hex.decode("00112233445566778899aabbccddeeff"); + private static final byte[] NONCE = Hex.decode("a1b2c3d4e5f60718"); + + private static final byte[] PLAIN_0 = Hex.decode("00000000000000000000000000000000ff"); + private static final byte[] PLAIN_1 = Hex.decode("48656c6c6f2c2053414b65212121212121"); + private static final byte[] PLAIN_2 = Hex.decode("deadbeef"); + + private static final byte[] CIPHER_SEQ0_0 = Hex.decode("7680ea16798357df88b11466330e24d3f200b0e6"); + private static final byte[] CIPHER_SEQ0_1 = Hex.decode("9b92fdd551a0cbfc63b5fae41e888ca53c011265"); + private static final byte[] CIPHER_SEQ0_2 = Hex.decode("60c26e3b02e1a3"); + private static final byte[] CIPHER_SEQ1 = Hex.decode("28b3469d0ece854f9760df0c7be2e79d93006a3d"); + + @Test + void encryptMatchesPythonAtSeqZero() { + SeqCrypt sc = new SeqCrypt(KEY, NONCE, 0L); + assertArrayEquals(CIPHER_SEQ0_0, sc.encrypt(PLAIN_0)); + assertEquals(2L, sc.getTxSeq()); + assertArrayEquals(CIPHER_SEQ0_1, sc.encrypt(PLAIN_1)); + assertEquals(4L, sc.getTxSeq()); + assertArrayEquals(CIPHER_SEQ0_2, sc.encrypt(PLAIN_2)); + assertEquals(6L, sc.getTxSeq()); + } + + @Test + void encryptMatchesPythonAtSeqOne() { + SeqCrypt sc = new SeqCrypt(KEY, NONCE, 1L); + assertArrayEquals(CIPHER_SEQ1, sc.encrypt(PLAIN_1)); + assertEquals(3L, sc.getTxSeq()); + } + + @Test + void decryptRoundTripsThreeMessages() throws Exception { + SeqCrypt sc = new SeqCrypt(KEY, NONCE, 0L); + assertArrayEquals(PLAIN_0, sc.decrypt(CIPHER_SEQ0_0)); + assertEquals(2L, sc.getRxSeq()); + assertArrayEquals(PLAIN_1, sc.decrypt(CIPHER_SEQ0_1)); + assertEquals(4L, sc.getRxSeq()); + assertArrayEquals(PLAIN_2, sc.decrypt(CIPHER_SEQ0_2)); + assertEquals(6L, sc.getRxSeq()); + } + + @Test + void decryptRejectsTamperedMac() { + SeqCrypt sc = new SeqCrypt(KEY, NONCE, 0L); + byte[] corrupt = CIPHER_SEQ0_0.clone(); + corrupt[corrupt.length - 1] ^= (byte) 0x01; + assertThrows(MacFailureException.class, () -> sc.decrypt(corrupt)); + } + + @Test + void decryptRejectsTamperedCiphertext() { + SeqCrypt sc = new SeqCrypt(KEY, NONCE, 0L); + byte[] corrupt = CIPHER_SEQ0_0.clone(); + corrupt[0] ^= (byte) 0x01; + assertThrows(MacFailureException.class, () -> sc.decrypt(corrupt)); + } + + @Test + void encryptDecryptRoundTripIsInverse() throws Exception { + SeqCrypt tx = new SeqCrypt(KEY, NONCE, 42L); + SeqCrypt rx = new SeqCrypt(KEY, NONCE, 42L); + byte[] cipher = tx.encrypt(PLAIN_1); + byte[] recovered = rx.decrypt(cipher); + assertArrayEquals(PLAIN_1, recovered); + assertEquals(tx.getTxSeq(), rx.getRxSeq()); + } + + /** + * The 1-byte sequence field in the trailer wraps every 256 messages + * (since it is {@code (seq >>> 1) & 0xFF}). The receiver must reconstruct + * the full 32-bit sequence using its locally tracked {@code rxSeq} as the + * base and a delta in [0, 255]. This test exercises the wrap by sending a + * single message at {@code seq = 512} (trailer byte 0) to a receiver that + * still tracks {@code rxSeq = 510}. + */ + @Test + void decryptHandlesEightBitSequenceWrap() throws Exception { + SeqCrypt tx = new SeqCrypt(KEY, NONCE, 512L); + byte[] cipher = tx.encrypt(PLAIN_1); + assertEquals(0, cipher[cipher.length - 3] & 0xFF, + "trailer byte should be 0 at seq=512 (wrap-around point)"); + + SeqCrypt rx = new SeqCrypt(KEY, NONCE, 510L); + byte[] recovered = rx.decrypt(cipher); + assertArrayEquals(PLAIN_1, recovered); + assertEquals(514L, rx.getRxSeq()); + } +} From c794fa1e955a78f14f865695ed3114f0bf68d29c Mon Sep 17 00:00:00 2001 From: jlengelbrecht Date: Thu, 14 May 2026 01:21:35 -0400 Subject: [PATCH 05/10] Add Session: handshake state machine driven by handshake_N steps Port of pysake/session.py. Six handshake step methods named to mirror the reference Python (handshake0S, handshake1C, ... handshake5C) where the trailing S or C marks the originating side of the wire message, not the calling side. Construction accepts exactly one of clientKeyDb or serverKeyDb. The absent side cannot run the cryptographic permit check on its inbound permit and will fall through to a logged-only payload comparison and return false. This matches pysake behaviour and is required for the SAKE server role in JavaPumpConnector where only KEYDB_PUMP_EXTRACTED is available. MAC failures at every verifying step (handshake2S, handshake3C, the permit check inside handshake4S and handshake5C, and SeqCrypt.decrypt) throw MacFailureException. Callers cannot quietly ignore them. SessionTest replays the captured 780g_pairing_with_mobile.pcapng message sequence (__PUMP_TEST_MSGS_1 from pysake/constants.py) and asserts byte-for-byte parity at every derived field: client_key_material, client_nonce, derivation_key, handshake_auth_key server_key_material, server_nonce client_crypt.key, client_crypt.nonce, client_crypt.tx_seq/rx_seq server_crypt.tx_seq/rx_seq Two key() and nonce() accessors added to SeqCrypt so tests can verify the derived session key matches the AES-ECB(derivation_key, skm || ckm) computation without exposing internal state to mutation. --- .../java/org/openminimed/sake/SeqCrypt.java | 8 + .../java/org/openminimed/sake/Session.java | 260 ++++++++++++++++++ .../org/openminimed/sake/SessionTest.java | 158 +++++++++++ 3 files changed, 426 insertions(+) create mode 100644 lib/src/main/java/org/openminimed/sake/Session.java create mode 100644 lib/src/test/java/org/openminimed/sake/SessionTest.java diff --git a/lib/src/main/java/org/openminimed/sake/SeqCrypt.java b/lib/src/main/java/org/openminimed/sake/SeqCrypt.java index b1521b4..a582b1c 100644 --- a/lib/src/main/java/org/openminimed/sake/SeqCrypt.java +++ b/lib/src/main/java/org/openminimed/sake/SeqCrypt.java @@ -98,6 +98,14 @@ public byte[] decrypt(byte[] message) throws MacFailureException { return plaintext; } + public byte[] key() { + return key.clone(); + } + + public byte[] nonce() { + return nonce.clone(); + } + public long getTxSeq() { return txSeq; } diff --git a/lib/src/main/java/org/openminimed/sake/Session.java b/lib/src/main/java/org/openminimed/sake/Session.java new file mode 100644 index 0000000..08c8be0 --- /dev/null +++ b/lib/src/main/java/org/openminimed/sake/Session.java @@ -0,0 +1,260 @@ +package org.openminimed.sake; + +import org.openminimed.sake.crypto.AesCmac; +import org.openminimed.sake.crypto.AesEcb; + +import java.util.Arrays; +import java.util.Objects; + +/** + * Holds the cryptographic state of a single SAKE handshake. + * + *

Exactly one of {@code clientKeyDb} or {@code serverKeyDb} must be supplied + * at construction time: the absent side will be unable to perform the + * cryptographic permit check on its inbound permit message and will simply log + * the payload comparison instead.

+ * + *

Method names match the reference Python implementation + * ({@code pysake/session.py}). The trailing {@code S} / {@code C} marks which + * side the message originated from (server vs client), not which side is + * calling the method.

+ */ +public final class Session { + + /** Required length in bytes of every handshake message. */ + public static final int MESSAGE_SIZE = 20; + + private static final int KEY_MATERIAL_SIZE = 8; + private static final int NONCE_SIZE = 4; + private static final int PERMIT_SIZE = 16; + private static final int CMAC8_SIZE = 8; + + private final KeyDatabase clientKeyDb; + private final KeyDatabase serverKeyDb; + + private DeviceType serverDeviceType; + private DeviceType clientDeviceType; + + private byte[] clientKeyMaterial; + private byte[] clientNonce; + + private byte[] serverKeyMaterial; + private byte[] serverNonce; + + private byte[] derivationKey; + private byte[] handshakeAuthKey; + + private StaticKeys clientStaticKeys; + private StaticKeys serverStaticKeys; + + private SeqCrypt clientCrypt; + private SeqCrypt serverCrypt; + + public Session(KeyDatabase clientKeyDb, KeyDatabase serverKeyDb) { + int provided = (clientKeyDb != null ? 1 : 0) + (serverKeyDb != null ? 1 : 0); + if (provided != 1) { + throw new IllegalArgumentException( + "Exactly one of clientKeyDb or serverKeyDb is required, got " + provided); + } + this.clientKeyDb = clientKeyDb; + this.serverKeyDb = serverKeyDb; + } + + public void handshake0S(byte[] msg) { + checkLen(msg); + if (msg[1] != 0x01) { + throw new IllegalArgumentException("Unexpected byte at offset 1: " + (msg[1] & 0xFF)); + } + this.serverDeviceType = DeviceType.fromValue(msg[0] & 0xFF); + } + + public void handshake1C(byte[] msg) { + checkLen(msg); + this.clientKeyMaterial = Arrays.copyOfRange(msg, 0, 8); + this.clientDeviceType = DeviceType.fromValue(msg[8] & 0xFF); + this.clientNonce = Arrays.copyOfRange(msg, 9, 13); + + int cdt = clientDeviceType.value(); + int sdt = serverDeviceType.value(); + + StaticKeys staticKeys = null; + if (clientKeyDb != null && clientKeyDb.localDeviceType().value() == cdt) { + staticKeys = clientKeyDb.remoteDevices().get(DeviceType.fromValue(sdt)); + this.clientStaticKeys = staticKeys; + } + if (serverKeyDb != null && serverKeyDb.localDeviceType().value() == sdt) { + staticKeys = serverKeyDb.remoteDevices().get(DeviceType.fromValue(cdt)); + this.serverStaticKeys = staticKeys; + } + if (staticKeys == null) { + throw new IllegalStateException( + "No keys available for client device type " + cdt + + " and server device type " + sdt); + } + + this.derivationKey = staticKeys.derivationKey(); + this.handshakeAuthKey = staticKeys.handshakeAuthKey(); + } + + public void handshake2S(byte[] msg) throws MacFailureException { + checkLen(msg); + byte[] received = Arrays.copyOfRange(msg, 0, CMAC8_SIZE); + byte[] serverKm = Arrays.copyOfRange(msg, 8, 16); + byte[] serverN = Arrays.copyOfRange(msg, 16, 20); + + AesCmac auth = cmac8(clientKeyMaterial, serverKm, derivationKey, handshakeAuthKey); + if (!auth.verify(received)) { + throw new MacFailureException("handshake2S CMAC8 verification failed"); + } + + this.serverKeyMaterial = serverKm; + this.serverNonce = serverN; + } + + public void handshake3C(byte[] msg) throws MacFailureException { + checkLen(msg); + byte[] received = Arrays.copyOfRange(msg, 0, CMAC8_SIZE); + + AesCmac auth1 = cmac8(clientKeyMaterial, serverKeyMaterial, derivationKey, handshakeAuthKey); + byte[] auth1Tag = auth1.digest(); + + byte[] inner = new byte[CMAC8_SIZE + KEY_MATERIAL_SIZE + AesCmac.BLOCK_SIZE]; + System.arraycopy(auth1Tag, 0, inner, 0, CMAC8_SIZE); + System.arraycopy(serverKeyMaterial, 0, inner, CMAC8_SIZE, KEY_MATERIAL_SIZE); + System.arraycopy(derivationKey, 0, inner, CMAC8_SIZE + KEY_MATERIAL_SIZE, AesCmac.BLOCK_SIZE); + + AesCmac auth2 = new AesCmac(handshakeAuthKey, CMAC8_SIZE); + auth2.update(inner); + if (!auth2.verify(received)) { + throw new MacFailureException("handshake3C CMAC8 verification failed"); + } + + createCrypts(); + } + + public boolean handshake4S(byte[] msg) throws MacFailureException { + checkLen(msg); + byte[] decrypted = serverCrypt.decrypt(msg); + byte[] payload = Arrays.copyOfRange(decrypted, 0, PERMIT_SIZE); + return checkPermit(payload, clientStaticKeys, serverStaticKeys, serverDeviceType.value()); + } + + public boolean handshake5C(byte[] msg) throws MacFailureException { + checkLen(msg); + byte[] decrypted = clientCrypt.decrypt(msg); + // pysake takes plaintext[:-1] but the permit body is exactly 16 bytes (PERMIT_SIZE). + byte[] payload = Arrays.copyOfRange(decrypted, 0, PERMIT_SIZE); + return checkPermit(payload, serverStaticKeys, clientStaticKeys, clientDeviceType.value()); + } + + /** + * Compute the 8-byte CMAC over {@code serverKm || clientKm || derivationKey}. + * + * @return a primed {@link AesCmac} ready to {@link AesCmac#digest()} or + * {@link AesCmac#verify(byte[])}. + */ + public static AesCmac cmac8(byte[] clientKm, byte[] serverKm, + byte[] derivationKey, byte[] handshakeAuthKey) { + byte[] msg = new byte[32]; + System.arraycopy(serverKm, 0, msg, 0, KEY_MATERIAL_SIZE); + System.arraycopy(clientKm, 0, msg, KEY_MATERIAL_SIZE, KEY_MATERIAL_SIZE); + System.arraycopy(derivationKey, 0, msg, 2 * KEY_MATERIAL_SIZE, AesCmac.BLOCK_SIZE); + AesCmac cmac = new AesCmac(handshakeAuthKey, CMAC8_SIZE); + cmac.update(msg); + return cmac; + } + + /** @throws IllegalArgumentException if {@code msg} is not exactly {@value #MESSAGE_SIZE} bytes. */ + public static void checkLen(byte[] msg) { + Objects.requireNonNull(msg, "msg"); + if (msg.length != MESSAGE_SIZE) { + throw new IllegalArgumentException( + "Invalid message length: " + msg.length + ", expected " + MESSAGE_SIZE); + } + } + + private void createCrypts() { + byte[] kmConcat = new byte[2 * KEY_MATERIAL_SIZE]; + System.arraycopy(serverKeyMaterial, 0, kmConcat, 0, KEY_MATERIAL_SIZE); + System.arraycopy(clientKeyMaterial, 0, kmConcat, KEY_MATERIAL_SIZE, KEY_MATERIAL_SIZE); + byte[] sessionKey = AesEcb.encryptBlock(derivationKey, kmConcat); + + byte[] sessionNonce = new byte[2 * NONCE_SIZE]; + System.arraycopy(clientNonce, 0, sessionNonce, 0, NONCE_SIZE); + System.arraycopy(serverNonce, 0, sessionNonce, NONCE_SIZE, NONCE_SIZE); + + this.clientCrypt = new SeqCrypt(sessionKey, sessionNonce, 0L); + this.serverCrypt = new SeqCrypt(sessionKey, sessionNonce, 1L); + } + + private boolean checkPermit(byte[] payload, + StaticKeys verifierStaticKeys, + StaticKeys proverStaticKeys, + int proverDeviceType) throws MacFailureException { + if (verifierStaticKeys == null) { + return false; + } + byte[] plain = AesEcb.decryptBlock(verifierStaticKeys.permitDecryptKey(), payload); + AesCmac auth = new AesCmac(verifierStaticKeys.permitAuthKey(), 4); + auth.update(Arrays.copyOfRange(plain, 0, 12)); + if (!auth.verify(Arrays.copyOfRange(plain, 12, 16))) { + throw new MacFailureException("Permit auth tag verification failed"); + } + return plain[0] == 0 && (plain[1] & 0xFF) == proverDeviceType; + } + + public DeviceType serverDeviceType() { + return serverDeviceType; + } + + public DeviceType clientDeviceType() { + return clientDeviceType; + } + + public byte[] clientKeyMaterial() { + return clientKeyMaterial == null ? null : clientKeyMaterial.clone(); + } + + public byte[] clientNonce() { + return clientNonce == null ? null : clientNonce.clone(); + } + + public byte[] serverKeyMaterial() { + return serverKeyMaterial == null ? null : serverKeyMaterial.clone(); + } + + public byte[] serverNonce() { + return serverNonce == null ? null : serverNonce.clone(); + } + + public byte[] derivationKey() { + return derivationKey == null ? null : derivationKey.clone(); + } + + public byte[] handshakeAuthKey() { + return handshakeAuthKey == null ? null : handshakeAuthKey.clone(); + } + + public StaticKeys clientStaticKeys() { + return clientStaticKeys; + } + + public StaticKeys serverStaticKeys() { + return serverStaticKeys; + } + + public SeqCrypt clientCrypt() { + return clientCrypt; + } + + public SeqCrypt serverCrypt() { + return serverCrypt; + } + + /** Package-private setter used by {@code SakeServer} to apply post-handshake adjustments. */ + void setServerCryptRxSeq(long value) { + if (serverCrypt != null) { + serverCrypt.setRxSeq(value); + } + } +} diff --git a/lib/src/test/java/org/openminimed/sake/SessionTest.java b/lib/src/test/java/org/openminimed/sake/SessionTest.java new file mode 100644 index 0000000..b9a5724 --- /dev/null +++ b/lib/src/test/java/org/openminimed/sake/SessionTest.java @@ -0,0 +1,158 @@ +package org.openminimed.sake; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Parity tests for {@link Session} driven by the captured pump pairing pcap + * embedded in {@code pysake/constants.py} as {@code __PUMP_TEST_MSGS_1}. + * + *

The expected per-checkpoint state was captured from a harness driving + * {@link Session} in the reference Python implementation against the same + * key database and message sequence. Any divergence in any derived field + * causes an assertion to fail and points at the responsible step.

+ */ +class SessionTest { + + private static final String KEY_DB_HEX = + "f75995e70401011bc1bf7cbf36fa1e2367d795ff09211903da6afbe986b650f1" + + "4179c0e6852e0ce393781078ffc6f51919e2eaefbde69b8eca21e41ab59b881a" + + "0bea0286ea91dc7582a86a714e1737f558f0d66dc1895c"; + + private static final byte[][] PUMP_TEST_MSGS = new byte[][]{ + Hex.decode("0401e2f09017a98f9f01cc56492fbacd4576e92b"), + Hex.decode("42060e9f344e9312016ee8854d357f659b6b00ba"), + Hex.decode("fdeeb13d04c3f18d272630ebeabe7c3a4d4d27b9"), + Hex.decode("c02cec4ffb99affcb553a10fa6c55bb13d9fbacf"), + Hex.decode("157d8e90214418a0e3d5f0517eebf4a82e00c02e"), + Hex.decode("9b36f393b296fa84a757809859fc84a5c300d59b"), + }; + + private static KeyDatabase keyDb() { + return KeyDatabase.fromBytes(Hex.decode(KEY_DB_HEX)); + } + + @Test + void rejectsConstructionWithNeitherKeyDb() { + assertThrows(IllegalArgumentException.class, () -> new Session(null, null)); + } + + @Test + void rejectsConstructionWithBothKeyDbs() { + assertThrows(IllegalArgumentException.class, () -> new Session(keyDb(), keyDb())); + } + + @Test + void rejectsWrongMessageLength() { + Session sess = new Session(null, keyDb()); + assertThrows(IllegalArgumentException.class, () -> sess.handshake0S(new byte[19])); + } + + @Test + void handshake0SCapturesServerDeviceType() { + Session sess = new Session(null, keyDb()); + sess.handshake0S(PUMP_TEST_MSGS[0]); + assertEquals(DeviceType.MOBILE_APPLICATION, sess.serverDeviceType()); + } + + @Test + void handshake1CDerivesKeysFromKeyDb() { + Session sess = new Session(null, keyDb()); + sess.handshake0S(PUMP_TEST_MSGS[0]); + sess.handshake1C(PUMP_TEST_MSGS[1]); + + assertEquals(DeviceType.INSULIN_PUMP, sess.clientDeviceType()); + assertArrayEquals(Hex.decode("42060e9f344e9312"), sess.clientKeyMaterial()); + assertArrayEquals(Hex.decode("6ee8854d"), sess.clientNonce()); + assertArrayEquals(Hex.decode("1bc1bf7cbf36fa1e2367d795ff092119"), sess.derivationKey()); + assertArrayEquals(Hex.decode("03da6afbe986b650f14179c0e6852e0c"), sess.handshakeAuthKey()); + assertNotNull(sess.serverStaticKeys()); + } + + @Test + void handshake2SVerifiesAndCapturesServerKeyMaterial() throws Exception { + Session sess = new Session(null, keyDb()); + sess.handshake0S(PUMP_TEST_MSGS[0]); + sess.handshake1C(PUMP_TEST_MSGS[1]); + sess.handshake2S(PUMP_TEST_MSGS[2]); + + assertArrayEquals(Hex.decode("272630ebeabe7c3a"), sess.serverKeyMaterial()); + assertArrayEquals(Hex.decode("4d4d27b9"), sess.serverNonce()); + } + + @Test + void handshake3CCreatesSeqCryptsWithDerivedKey() throws Exception { + Session sess = new Session(null, keyDb()); + sess.handshake0S(PUMP_TEST_MSGS[0]); + sess.handshake1C(PUMP_TEST_MSGS[1]); + sess.handshake2S(PUMP_TEST_MSGS[2]); + sess.handshake3C(PUMP_TEST_MSGS[3]); + + byte[] expectedKey = Hex.decode("99ab5c7c113f85eefeb921da4094a886"); + byte[] expectedNonce = Hex.decode("6ee8854d4d4d27b9"); + + assertArrayEquals(expectedKey, sess.clientCrypt().key()); + assertArrayEquals(expectedNonce, sess.clientCrypt().nonce()); + assertEquals(0L, sess.clientCrypt().getTxSeq()); + assertEquals(0L, sess.clientCrypt().getRxSeq()); + + assertArrayEquals(expectedKey, sess.serverCrypt().key()); + assertEquals(1L, sess.serverCrypt().getTxSeq()); + assertEquals(1L, sess.serverCrypt().getRxSeq()); + } + + @Test + void handshake4SDecryptsAndReturnsFalseWithoutClientKeyDb() throws Exception { + Session sess = new Session(null, keyDb()); + sess.handshake0S(PUMP_TEST_MSGS[0]); + sess.handshake1C(PUMP_TEST_MSGS[1]); + sess.handshake2S(PUMP_TEST_MSGS[2]); + sess.handshake3C(PUMP_TEST_MSGS[3]); + boolean ok = sess.handshake4S(PUMP_TEST_MSGS[4]); + assertFalse(ok, "server-only session has no verifier keys for handshake_4_s"); + assertEquals(3L, sess.serverCrypt().getRxSeq()); + } + + @Test + void handshake5CVerifiesClientPermit() throws Exception { + Session sess = new Session(null, keyDb()); + sess.handshake0S(PUMP_TEST_MSGS[0]); + sess.handshake1C(PUMP_TEST_MSGS[1]); + sess.handshake2S(PUMP_TEST_MSGS[2]); + sess.handshake3C(PUMP_TEST_MSGS[3]); + sess.handshake4S(PUMP_TEST_MSGS[4]); + boolean ok = sess.handshake5C(PUMP_TEST_MSGS[5]); + assertTrue(ok, "client permit must verify against server static keys"); + assertEquals(2L, sess.clientCrypt().getRxSeq()); + } + + @Test + void fullHandshakeMatchesCapturedFinalState() throws Exception { + Session sess = new Session(null, keyDb()); + sess.handshake0S(PUMP_TEST_MSGS[0]); + sess.handshake1C(PUMP_TEST_MSGS[1]); + sess.handshake2S(PUMP_TEST_MSGS[2]); + sess.handshake3C(PUMP_TEST_MSGS[3]); + assertFalse(sess.handshake4S(PUMP_TEST_MSGS[4])); + assertTrue(sess.handshake5C(PUMP_TEST_MSGS[5])); + + assertEquals(2L, sess.clientCrypt().getRxSeq()); + assertEquals(3L, sess.serverCrypt().getRxSeq()); + } + + @Test + void handshake2SThrowsOnTamperedMac() throws Exception { + Session sess = new Session(null, keyDb()); + sess.handshake0S(PUMP_TEST_MSGS[0]); + sess.handshake1C(PUMP_TEST_MSGS[1]); + byte[] tampered = PUMP_TEST_MSGS[2].clone(); + tampered[0] ^= (byte) 0x01; + assertThrows(MacFailureException.class, () -> sess.handshake2S(tampered)); + } +} From 9fe046e6b66a8ee78a4fed02dc7a46257cb8335e Mon Sep 17 00:00:00 2001 From: jlengelbrecht Date: Thu, 14 May 2026 01:30:39 -0400 Subject: [PATCH 06/10] Add Peer, SakeServer and SakeClient: handshake orchestrators Server stage progression: 0 (consume 20 zero bytes, emit msg0) -> 1 (consume msg1, emit msg2) -> 3 (consume msg3, emit msg4) -> 5 (consume msg5, emit null) -> 6. Client stage progression: 0 (consume msg0, emit msg1) -> 2 (consume msg2, emit msg3) -> 4 (consume msg4, emit msg5) -> 6. Both classes are thin orchestrators over Session: they own the stage counter and the RNG source used to populate the random fields chosen fresh per session (msg0 filler, server key material and nonce on the server side; client key material, nonce and msg3 filler on the client side). The msg4 pad byte (server) is 0x69 and the msg5 pad byte (client) is 0x00 in production, matching the reference Python. RngSource is an injectable interface so test code can drive the handshake deterministically. SecureRandomRngSource is the default production implementation. SakeServerTest drives the server with the captured msg0/msg2 random fields and the recovered 0xf7 msg4 pad byte and asserts each emitted message byte-for-byte against __PUMP_TEST_MSGS_1 from the reference implementation - the captured 780g_pairing_with_mobile.pcapng trace. SakeClientTest runs a fresh client and server through the full handshake using a matched pair of custom key databases generated by the reference test_key_db_gen.py harness and asserts the derived session keys and nonces match across both peers. Note: the pysake _brute_force_ghost_byte helper used by its debug mode is broken upstream (references crypt_obj.seq which was renamed to tx_seq/rx_seq). The msg4 pad byte was instead recovered directly from the keystream as captured[16] XOR keystream[16]. --- .../main/java/org/openminimed/sake/Peer.java | 20 +++ .../java/org/openminimed/sake/RngSource.java | 15 ++ .../java/org/openminimed/sake/SakeClient.java | 141 ++++++++++++++++ .../java/org/openminimed/sake/SakeServer.java | 153 ++++++++++++++++++ .../sake/SecureRandomRngSource.java | 18 +++ .../org/openminimed/sake/QueuedRngSource.java | 35 ++++ .../org/openminimed/sake/SakeClientTest.java | 102 ++++++++++++ .../org/openminimed/sake/SakeServerTest.java | 119 ++++++++++++++ 8 files changed, 603 insertions(+) create mode 100644 lib/src/main/java/org/openminimed/sake/Peer.java create mode 100644 lib/src/main/java/org/openminimed/sake/RngSource.java create mode 100644 lib/src/main/java/org/openminimed/sake/SakeClient.java create mode 100644 lib/src/main/java/org/openminimed/sake/SakeServer.java create mode 100644 lib/src/main/java/org/openminimed/sake/SecureRandomRngSource.java create mode 100644 lib/src/test/java/org/openminimed/sake/QueuedRngSource.java create mode 100644 lib/src/test/java/org/openminimed/sake/SakeClientTest.java create mode 100644 lib/src/test/java/org/openminimed/sake/SakeServerTest.java diff --git a/lib/src/main/java/org/openminimed/sake/Peer.java b/lib/src/main/java/org/openminimed/sake/Peer.java new file mode 100644 index 0000000..1e4c341 --- /dev/null +++ b/lib/src/main/java/org/openminimed/sake/Peer.java @@ -0,0 +1,20 @@ +package org.openminimed.sake; + +/** + * Base class for {@link SakeServer} and {@link SakeClient}: tracks the handshake stage. + * + *

Stage progression for a server: 0 → 1 → 3 → 5 → 6.

+ *

Stage progression for a client: 0 → 2 → 4 → 6.

+ */ +public abstract class Peer { + + private int stage = 0; + + public final int getStage() { + return stage; + } + + protected final void incrementStage() { + stage++; + } +} diff --git a/lib/src/main/java/org/openminimed/sake/RngSource.java b/lib/src/main/java/org/openminimed/sake/RngSource.java new file mode 100644 index 0000000..7c91cfc --- /dev/null +++ b/lib/src/main/java/org/openminimed/sake/RngSource.java @@ -0,0 +1,15 @@ +package org.openminimed.sake; + +/** + * Source of random bytes used to populate handshake fields the server / client + * are expected to choose freshly per session. + * + *

The default implementation is backed by {@link java.security.SecureRandom}. + * Tests can substitute a deterministic source to drive a server or client + * against a captured packet trace.

+ */ +public interface RngSource { + + /** @return {@code n} fresh random bytes. */ + byte[] nextBytes(int n); +} diff --git a/lib/src/main/java/org/openminimed/sake/SakeClient.java b/lib/src/main/java/org/openminimed/sake/SakeClient.java new file mode 100644 index 0000000..d34c2f6 --- /dev/null +++ b/lib/src/main/java/org/openminimed/sake/SakeClient.java @@ -0,0 +1,141 @@ +package org.openminimed.sake; + +import org.openminimed.sake.crypto.AesCmac; + +import java.util.Arrays; +import java.util.Objects; + +/** + * Client-side wrapper around {@link Session}. + * + *

Stage progression: 0 (consume server msg0, emit msg1) → 2 (consume msg2, + * emit msg3) → 4 (consume msg4, emit msg5) → 6 (done).

+ */ +public final class SakeClient extends Peer { + + /** Constant pad byte appended to the client's encrypted permit (msg5) plaintext. */ + static final byte DEFAULT_MSG5_PAD = (byte) 0x00; + + private final Session session; + private final DeviceType localDeviceType; + private final RngSource rng; + private byte msg5Pad = DEFAULT_MSG5_PAD; + + public SakeClient(KeyDatabase keyDb) { + this(keyDb, DeviceType.PRIMARY_DISPLAY, new SecureRandomRngSource()); + } + + public SakeClient(KeyDatabase keyDb, DeviceType localDeviceType) { + this(keyDb, localDeviceType, new SecureRandomRngSource()); + } + + public SakeClient(KeyDatabase keyDb, DeviceType localDeviceType, RngSource rng) { + Objects.requireNonNull(keyDb, "keyDb"); + Objects.requireNonNull(localDeviceType, "localDeviceType"); + Objects.requireNonNull(rng, "rng"); + this.session = new Session(keyDb, null); + this.localDeviceType = localDeviceType; + this.rng = rng; + } + + /** + * Drive the handshake one step. + * + * @param input the 20-byte message just received from the server. + * @return the 20-byte message to send back to the server. + * @throws MacFailureException if any CMAC verification fails. + */ + public byte[] handshake(byte[] input) throws MacFailureException { + Session.checkLen(input); + + int stage = getStage(); + if (stage == 0) { + session.handshake0S(input); + incrementStage(); + + byte[] msg1 = buildHandshake1C(); + session.handshake1C(msg1); + incrementStage(); + return msg1; + } + + if (stage == 2) { + session.handshake2S(input); + incrementStage(); + + byte[] msg3 = buildHandshake3C(); + session.handshake3C(msg3); + incrementStage(); + return msg3; + } + + if (stage == 4) { + boolean ok = session.handshake4S(input); + if (!ok) { + throw new MacFailureException("Permit verification failed at stage 4"); + } + incrementStage(); + + byte[] msg5 = buildHandshake5C(); + incrementStage(); + return msg5; + } + + throw new IllegalStateException("Invalid stage for SakeClient.handshake(): " + stage); + } + + public Session session() { + return session; + } + + public DeviceType localDeviceType() { + return localDeviceType; + } + + void setMsg5Pad(byte value) { + this.msg5Pad = value; + } + + private byte[] buildHandshake1C() { + byte[] key = rng.nextBytes(8); + byte[] nonce = rng.nextBytes(4); + + byte[] msg = new byte[Session.MESSAGE_SIZE]; + System.arraycopy(key, 0, msg, 0, 8); + msg[8] = (byte) localDeviceType.value(); + System.arraycopy(nonce, 0, msg, 9, 4); + // Bytes 13..19 remain zero, matching the reference Python implementation. + return msg; + } + + private byte[] buildHandshake3C() { + AesCmac auth1 = Session.cmac8( + session.clientKeyMaterial(), + session.serverKeyMaterial(), + session.derivationKey(), + session.handshakeAuthKey()); + byte[] auth1Tag = auth1.digest(); + + byte[] inner = new byte[8 + 8 + 16]; + System.arraycopy(auth1Tag, 0, inner, 0, 8); + System.arraycopy(session.serverKeyMaterial(), 0, inner, 8, 8); + System.arraycopy(session.derivationKey(), 0, inner, 16, 16); + + AesCmac auth2 = new AesCmac(session.handshakeAuthKey(), 8); + auth2.update(inner); + byte[] prefix = auth2.digest(); + + byte[] filler = rng.nextBytes(12); + byte[] out = new byte[Session.MESSAGE_SIZE]; + System.arraycopy(prefix, 0, out, 0, 8); + System.arraycopy(filler, 0, out, 8, 12); + return out; + } + + private byte[] buildHandshake5C() { + byte[] payload = session.clientStaticKeys().handshakePayload(); + byte[] plaintext = Arrays.copyOf(payload, 17); + plaintext[16] = msg5Pad; + return session.clientCrypt().encrypt(plaintext); + } +} diff --git a/lib/src/main/java/org/openminimed/sake/SakeServer.java b/lib/src/main/java/org/openminimed/sake/SakeServer.java new file mode 100644 index 0000000..a01b605 --- /dev/null +++ b/lib/src/main/java/org/openminimed/sake/SakeServer.java @@ -0,0 +1,153 @@ +package org.openminimed.sake; + +import org.openminimed.sake.crypto.AesCmac; + +import java.util.Arrays; +import java.util.Objects; + +/** + * Server-side wrapper around {@link Session}: drives the handshake state machine + * and builds outgoing messages from the running state. + * + *

Stage progression: 0 (consume 20 zero bytes, emit msg0) → 1 (consume msg1, + * emit msg2) → 3 (consume msg3, emit msg4) → 5 (consume msg5, emit {@code null} + * to signal completion) → 6 (done).

+ */ +public final class SakeServer extends Peer { + + /** Constant pad byte appended to the server's encrypted permit (msg4) plaintext. */ + static final byte DEFAULT_MSG4_PAD = (byte) 0x69; + + private final Session session; + private final DeviceType localDeviceType; + private final RngSource rng; + private byte msg4Pad = DEFAULT_MSG4_PAD; + + public SakeServer(KeyDatabase keyDb) { + this(keyDb, DeviceType.MOBILE_APPLICATION, new SecureRandomRngSource()); + } + + public SakeServer(KeyDatabase keyDb, DeviceType localDeviceType) { + this(keyDb, localDeviceType, new SecureRandomRngSource()); + } + + public SakeServer(KeyDatabase keyDb, DeviceType localDeviceType, RngSource rng) { + Objects.requireNonNull(keyDb, "keyDb"); + Objects.requireNonNull(localDeviceType, "localDeviceType"); + Objects.requireNonNull(rng, "rng"); + this.session = new Session(null, keyDb); + this.localDeviceType = localDeviceType; + this.rng = rng; + } + + /** + * Drive the handshake one step. + * + * @param input the 20-byte message just received from the client. At stage 0 + * this must be 20 zero bytes (the wake-up frame sent over the + * SAKE characteristic when the peripheral subscribes to + * notifications). + * @return the 20-byte message to send back to the client, or {@code null} once + * the handshake completes at stage 5. + * @throws MacFailureException if any CMAC verification fails. + */ + public byte[] handshake(byte[] input) throws MacFailureException { + Session.checkLen(input); + + int stage = getStage(); + if (stage == 0) { + for (byte b : input) { + if (b != 0) { + throw new IllegalArgumentException("Stage 0 expects 20 zero bytes"); + } + } + byte[] msg0 = buildHandshake0S(); + session.handshake0S(msg0); + incrementStage(); + return msg0; + } + + if (stage == 1) { + session.handshake1C(input); + incrementStage(); + + byte[] msg2 = buildHandshake2S(); + session.handshake2S(msg2); + incrementStage(); + return msg2; + } + + if (stage == 3) { + session.handshake3C(input); + incrementStage(); + + byte[] msg4 = buildHandshake4S(); + incrementStage(); + return msg4; + } + + if (stage == 5) { + boolean ok = session.handshake5C(input); + if (!ok) { + throw new MacFailureException("Permit verification failed at stage 5"); + } + incrementStage(); + + // Reset server_crypt.rx_seq so subsequent session traffic is decoded + // with the right starting sequence number. + session.setServerCryptRxSeq(2L); + return null; + } + + throw new IllegalStateException("Handshake already complete (stage " + stage + ")"); + } + + public Session session() { + return session; + } + + public DeviceType localDeviceType() { + return localDeviceType; + } + + /** + * Override the msg4 pad byte. Package-private; only the parity tests use this + * to reproduce a captured packet trace bit-for-bit. + */ + void setMsg4Pad(byte value) { + this.msg4Pad = value; + } + + private byte[] buildHandshake0S() { + byte[] msg = new byte[Session.MESSAGE_SIZE]; + msg[0] = (byte) localDeviceType.value(); + msg[1] = 0x01; + byte[] filler = rng.nextBytes(Session.MESSAGE_SIZE - 2); + System.arraycopy(filler, 0, msg, 2, filler.length); + return msg; + } + + private byte[] buildHandshake2S() { + byte[] serverKm = rng.nextBytes(8); + byte[] serverNonce = rng.nextBytes(4); + AesCmac auth = Session.cmac8( + session.clientKeyMaterial(), + serverKm, + session.derivationKey(), + session.handshakeAuthKey()); + byte[] prefix = auth.digest(); + + byte[] out = new byte[Session.MESSAGE_SIZE]; + System.arraycopy(prefix, 0, out, 0, 8); + System.arraycopy(serverKm, 0, out, 8, 8); + System.arraycopy(serverNonce, 0, out, 16, 4); + return out; + } + + private byte[] buildHandshake4S() { + byte[] payload = session.serverStaticKeys().handshakePayload(); + byte[] plaintext = Arrays.copyOf(payload, 17); + plaintext[16] = msg4Pad; + return session.serverCrypt().encrypt(plaintext); + } +} diff --git a/lib/src/main/java/org/openminimed/sake/SecureRandomRngSource.java b/lib/src/main/java/org/openminimed/sake/SecureRandomRngSource.java new file mode 100644 index 0000000..6f1456a --- /dev/null +++ b/lib/src/main/java/org/openminimed/sake/SecureRandomRngSource.java @@ -0,0 +1,18 @@ +package org.openminimed.sake; + +import java.security.SecureRandom; + +/** + * Default {@link RngSource} backed by {@link SecureRandom}. + */ +public final class SecureRandomRngSource implements RngSource { + + private final SecureRandom rng = new SecureRandom(); + + @Override + public byte[] nextBytes(int n) { + byte[] out = new byte[n]; + rng.nextBytes(out); + return out; + } +} diff --git a/lib/src/test/java/org/openminimed/sake/QueuedRngSource.java b/lib/src/test/java/org/openminimed/sake/QueuedRngSource.java new file mode 100644 index 0000000..1ad7587 --- /dev/null +++ b/lib/src/test/java/org/openminimed/sake/QueuedRngSource.java @@ -0,0 +1,35 @@ +package org.openminimed.sake; + +import java.util.ArrayDeque; +import java.util.Arrays; +import java.util.Deque; + +/** + * Deterministic {@link RngSource} that returns pre-queued byte arrays in order. + * + *

Used by parity tests to drive {@link SakeServer} / {@link SakeClient} + * through a captured packet trace.

+ */ +final class QueuedRngSource implements RngSource { + + private final Deque queue; + + QueuedRngSource(byte[]... values) { + this.queue = new ArrayDeque<>(Arrays.asList(values)); + } + + @Override + public byte[] nextBytes(int n) { + byte[] next = queue.pollFirst(); + if (next == null) { + throw new IllegalStateException( + "QueuedRngSource is empty (caller asked for " + n + " bytes)"); + } + if (next.length != n) { + throw new IllegalStateException( + "QueuedRngSource size mismatch: caller asked for " + n + + " bytes but next queued value is " + next.length); + } + return next.clone(); + } +} diff --git a/lib/src/test/java/org/openminimed/sake/SakeClientTest.java b/lib/src/test/java/org/openminimed/sake/SakeClientTest.java new file mode 100644 index 0000000..e6a54ce --- /dev/null +++ b/lib/src/test/java/org/openminimed/sake/SakeClientTest.java @@ -0,0 +1,102 @@ +package org.openminimed.sake; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * End-to-end tests for {@link SakeClient}: driving a fresh client against a + * fresh server using a matched pair of custom key databases generated by + * {@code test_key_db_gen.py} in the reference Python implementation. + * + *

The two databases share the same derivation key, handshake auth key and + * permit key set; together they form a complete two-sided test pair, the + * smallest configuration that lets both permit checks succeed.

+ */ +class SakeClientTest { + + private static final String KEYDB_CUSTOM_SERVER_HEX = + "b079cdc504010144455249564154494f4e5f5f5f4b4559484e4453484b455f41" + + "5554485f4b455950484f4e455f5045524d49545f454e4350484f4e455f5045" + + "524d49545f4d4143ad14ad2780437db892d5650567d491b9"; + + private static final String KEYDB_CUSTOM_CLIENT_HEX = + "db8c1f2801010444455249564154494f4e5f5f5f4b4559484e4453484b455f41" + + "5554485f4b455950554d505f5045524d49545f454e435250554d505f504552" + + "4d49545f434d4143f2f8dbbb51563d4fa98fdaff0042a432"; + + private static KeyDatabase serverKeyDb() { + return KeyDatabase.fromBytes(Hex.decode(KEYDB_CUSTOM_SERVER_HEX)); + } + + private static KeyDatabase clientKeyDb() { + return KeyDatabase.fromBytes(Hex.decode(KEYDB_CUSTOM_CLIENT_HEX)); + } + + @Test + void clientAndServerCompleteHandshakeAgainstEachOther() throws Exception { + SakeServer server = new SakeServer(serverKeyDb(), DeviceType.MOBILE_APPLICATION); + SakeClient client = new SakeClient(clientKeyDb(), DeviceType.INSULIN_PUMP); + + byte[] msg0 = server.handshake(new byte[20]); + assertNotNull(msg0); + assertEquals(1, server.getStage()); + + byte[] msg1 = client.handshake(msg0); + assertNotNull(msg1); + assertEquals(2, client.getStage()); + + byte[] msg2 = server.handshake(msg1); + assertNotNull(msg2); + assertEquals(3, server.getStage()); + + byte[] msg3 = client.handshake(msg2); + assertNotNull(msg3); + assertEquals(4, client.getStage()); + + byte[] msg4 = server.handshake(msg3); + assertNotNull(msg4); + assertEquals(5, server.getStage()); + + byte[] msg5 = client.handshake(msg4); + assertNotNull(msg5); + assertEquals(6, client.getStage()); + + byte[] done = server.handshake(msg5); + assertEquals(6, server.getStage()); + assertEquals(null, done); + } + + @Test + void handshakeProducesUsableSessionState() throws Exception { + SakeServer server = new SakeServer(serverKeyDb(), DeviceType.MOBILE_APPLICATION); + SakeClient client = new SakeClient(clientKeyDb(), DeviceType.INSULIN_PUMP); + + byte[] msg0 = server.handshake(new byte[20]); + byte[] msg1 = client.handshake(msg0); + byte[] msg2 = server.handshake(msg1); + byte[] msg3 = client.handshake(msg2); + byte[] msg4 = server.handshake(msg3); + byte[] msg5 = client.handshake(msg4); + server.handshake(msg5); + + // Both peers must agree on the derived session key. + assertArrayEquals( + server.session().clientCrypt().key(), + client.session().clientCrypt().key(), + "client_crypt key must match across peers"); + assertArrayEquals( + server.session().serverCrypt().key(), + client.session().serverCrypt().key(), + "server_crypt key must match across peers"); + assertArrayEquals( + server.session().clientCrypt().nonce(), + client.session().clientCrypt().nonce(), + "session nonce must match across peers"); + + assertTrue(server.getStage() == 6 && client.getStage() == 6); + } +} diff --git a/lib/src/test/java/org/openminimed/sake/SakeServerTest.java b/lib/src/test/java/org/openminimed/sake/SakeServerTest.java new file mode 100644 index 0000000..fe45df7 --- /dev/null +++ b/lib/src/test/java/org/openminimed/sake/SakeServerTest.java @@ -0,0 +1,119 @@ +package org.openminimed.sake; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * End-to-end parity tests for {@link SakeServer} against the captured 780G + * pairing pcap embedded in {@code pysake/constants.py} + * ({@code __PUMP_TEST_MSGS_1}). + * + *

The server is driven with a deterministic {@link QueuedRngSource} that + * replays the random fields chosen during the original pcap (server msg0 + * filler, server key material, server nonce) and with the same {@code 0xf7} + * pad byte for msg4. Under those inputs the server must emit exactly the + * bytes recorded in the pcap.

+ */ +class SakeServerTest { + + private static final String PUMP_KEYDB_HEX = + "f75995e70401011bc1bf7cbf36fa1e2367d795ff09211903da6afbe986b650f1" + + "4179c0e6852e0ce393781078ffc6f51919e2eaefbde69b8eca21e41ab59b881a" + + "0bea0286ea91dc7582a86a714e1737f558f0d66dc1895c"; + + private static final byte[][] PUMP_TEST_MSGS = new byte[][]{ + Hex.decode("0401e2f09017a98f9f01cc56492fbacd4576e92b"), + Hex.decode("42060e9f344e9312016ee8854d357f659b6b00ba"), + Hex.decode("fdeeb13d04c3f18d272630ebeabe7c3a4d4d27b9"), + Hex.decode("c02cec4ffb99affcb553a10fa6c55bb13d9fbacf"), + Hex.decode("157d8e90214418a0e3d5f0517eebf4a82e00c02e"), + Hex.decode("9b36f393b296fa84a757809859fc84a5c300d59b"), + }; + + /** + * Pad byte for the msg4 plaintext that reproduces the captured pcap. Recovered + * from the keystream as {@code captured[16] XOR keystream[16]} at tx_seq=1. + */ + private static final byte CAPTURED_MSG4_PAD = (byte) 0xf7; + + private static SakeServer captureMatchingServer() { + QueuedRngSource rng = new QueuedRngSource( + Arrays.copyOfRange(PUMP_TEST_MSGS[0], 2, 20), + Arrays.copyOfRange(PUMP_TEST_MSGS[2], 8, 16), + Arrays.copyOfRange(PUMP_TEST_MSGS[2], 16, 20)); + SakeServer server = new SakeServer( + KeyDatabase.fromBytes(Hex.decode(PUMP_KEYDB_HEX)), + DeviceType.MOBILE_APPLICATION, + rng); + server.setMsg4Pad(CAPTURED_MSG4_PAD); + return server; + } + + @Test + void endToEndMatchesCapturedPumpTrace() throws Exception { + SakeServer server = captureMatchingServer(); + + byte[] out0 = server.handshake(new byte[20]); + assertArrayEquals(PUMP_TEST_MSGS[0], out0, "msg0 must match captured pcap"); + assertEquals(1, server.getStage()); + + byte[] out2 = server.handshake(PUMP_TEST_MSGS[1]); + assertArrayEquals(PUMP_TEST_MSGS[2], out2, "msg2 must match captured pcap"); + assertEquals(3, server.getStage()); + + byte[] out4 = server.handshake(PUMP_TEST_MSGS[3]); + assertArrayEquals(PUMP_TEST_MSGS[4], out4, "msg4 must match captured pcap"); + assertEquals(5, server.getStage()); + + byte[] out5 = server.handshake(PUMP_TEST_MSGS[5]); + assertNull(out5, "stage 5 returns null to signal completion"); + assertEquals(6, server.getStage()); + + // After completion the server_crypt.rx_seq has been reset to 2 so that + // subsequent session traffic decodes against the right starting seq. + assertEquals(2L, server.session().serverCrypt().getRxSeq()); + } + + @Test + void msg0IsEmittedAtStageZero() throws Exception { + SakeServer server = captureMatchingServer(); + byte[] out0 = server.handshake(new byte[20]); + assertNotNull(out0); + assertEquals(DeviceType.MOBILE_APPLICATION.value(), out0[0] & 0xFF); + assertEquals(0x01, out0[1]); + } + + @Test + void stageZeroRejectsNonZeroInput() { + SakeServer server = new SakeServer( + KeyDatabase.fromBytes(Hex.decode(PUMP_KEYDB_HEX))); + byte[] notZero = new byte[20]; + notZero[5] = 0x01; + assertThrows(IllegalArgumentException.class, () -> server.handshake(notZero)); + } + + @Test + void handshakeRejectsWrongLength() { + SakeServer server = new SakeServer( + KeyDatabase.fromBytes(Hex.decode(PUMP_KEYDB_HEX))); + assertThrows(IllegalArgumentException.class, () -> server.handshake(new byte[19])); + } + + @Test + void handshakeRejectsAdditionalInputAfterCompletion() throws Exception { + SakeServer server = captureMatchingServer(); + server.handshake(new byte[20]); + server.handshake(PUMP_TEST_MSGS[1]); + server.handshake(PUMP_TEST_MSGS[3]); + server.handshake(PUMP_TEST_MSGS[5]); + assertEquals(6, server.getStage()); + assertThrows(IllegalStateException.class, () -> server.handshake(new byte[20])); + } +} From 07dc6d69aec0a246027cffafab991a280cc7aa52 Mon Sep 17 00:00:00 2001 From: jlengelbrecht Date: Thu, 14 May 2026 01:53:50 -0400 Subject: [PATCH 07/10] Add Constants with the three baked-in key databases Ports KEYDB_G4_CGM, KEYDB_PUMP_EXTRACTED and KEYDB_PUMP_HARDCODED from pysake/constants.py as public static final fields, plus the AVAILABLE_KEYS list. These are the production-facing key databases that downstream consumers (in particular the SAKE handler in the JavaPumpConnector Android app) need to instantiate a SakeServer against. The CUSTOM_SERVER and CUSTOM_CLIENT databases used by test_key_db_gen are intentionally not exposed: they are test-pair fixtures only and live as test constants in SakeClientTest. --- .../java/org/openminimed/sake/Constants.java | 59 +++++++++++++++++++ .../org/openminimed/sake/ConstantsTest.java | 38 ++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 lib/src/main/java/org/openminimed/sake/Constants.java create mode 100644 lib/src/test/java/org/openminimed/sake/ConstantsTest.java diff --git a/lib/src/main/java/org/openminimed/sake/Constants.java b/lib/src/main/java/org/openminimed/sake/Constants.java new file mode 100644 index 0000000..302baa3 --- /dev/null +++ b/lib/src/main/java/org/openminimed/sake/Constants.java @@ -0,0 +1,59 @@ +package org.openminimed.sake; + +import java.util.Collections; +import java.util.List; + +/** + * Pre-baked key databases used by SAKE peers. + * + *

These are the three databases shipped with the reference Python + * implementation in {@code pysake/constants.py}. The hex strings are + * reproduced verbatim and parsed at class-init time.

+ */ +public final class Constants { + + /** Glucose sensor (G4 family) key database. Display-side. */ + public static final KeyDatabase KEYDB_G4_CGM = parse( + "5fe5928308010230f0b50df613f2e429c8c5e8713854add1a69b837235a3e974" + + "304d8055ccb397838b90823c73236d6a83dcc9db3a2a939ff16145ca4169ef93" + + "a7fa39b20962b05e57413bff8b3d61fce0dfef2c43b326"); + + /** Insulin pump database recovered from real pump firmware. Mobile-app-side. */ + public static final KeyDatabase KEYDB_PUMP_EXTRACTED = parse( + "f75995e70401011bc1bf7cbf36fa1e2367d795ff09211903da6afbe986b650f1" + + "4179c0e6852e0ce393781078ffc6f51919e2eaefbde69b8eca21e41ab59b881a" + + "0bea0286ea91dc7582a86a714e1737f558f0d66dc1895c"); + + /** Insulin pump database with hard-coded test keys. Mobile-app-side. */ + public static final KeyDatabase KEYDB_PUMP_HARDCODED = parse( + "c2cdfdd1040101fce36ed66ef21def3b0763975494b239038ebe8606f79a9bf0" + + "0d9f11b6db04c7c0434787cbf00d5476289c22288e2105ae40e01391837f9476" + + "fa5003895c5a1afe35662a2a6211826af016eebe30e4ba"); + + /** Databases available for production handshakes. */ + public static final List AVAILABLE_KEYS = Collections.unmodifiableList( + java.util.Arrays.asList(KEYDB_G4_CGM, KEYDB_PUMP_EXTRACTED, KEYDB_PUMP_HARDCODED)); + + private Constants() { + } + + private static KeyDatabase parse(String hex) { + return KeyDatabase.fromBytes(decode(hex)); + } + + private static byte[] decode(String hex) { + if ((hex.length() & 1) != 0) { + throw new IllegalArgumentException("Hex string has odd length"); + } + byte[] out = new byte[hex.length() / 2]; + for (int i = 0; i < out.length; i++) { + int high = Character.digit(hex.charAt(2 * i), 16); + int low = Character.digit(hex.charAt(2 * i + 1), 16); + if (high < 0 || low < 0) { + throw new IllegalArgumentException("Invalid hex character"); + } + out[i] = (byte) ((high << 4) | low); + } + return out; + } +} diff --git a/lib/src/test/java/org/openminimed/sake/ConstantsTest.java b/lib/src/test/java/org/openminimed/sake/ConstantsTest.java new file mode 100644 index 0000000..b3f0ac0 --- /dev/null +++ b/lib/src/test/java/org/openminimed/sake/ConstantsTest.java @@ -0,0 +1,38 @@ +package org.openminimed.sake; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ConstantsTest { + + @Test + void g4CgmLocalIsPrimaryDisplay() { + assertEquals(DeviceType.PRIMARY_DISPLAY, Constants.KEYDB_G4_CGM.localDeviceType()); + assertNotNull(Constants.KEYDB_G4_CGM.remoteDevices().get(DeviceType.GLUCOSE_SENSOR)); + } + + @Test + void pumpExtractedLocalIsMobileApplication() { + assertEquals(DeviceType.MOBILE_APPLICATION, + Constants.KEYDB_PUMP_EXTRACTED.localDeviceType()); + assertNotNull(Constants.KEYDB_PUMP_EXTRACTED.remoteDevices().get(DeviceType.INSULIN_PUMP)); + } + + @Test + void pumpHardcodedLocalIsMobileApplication() { + assertEquals(DeviceType.MOBILE_APPLICATION, + Constants.KEYDB_PUMP_HARDCODED.localDeviceType()); + assertNotNull(Constants.KEYDB_PUMP_HARDCODED.remoteDevices().get(DeviceType.INSULIN_PUMP)); + } + + @Test + void availableKeysExposesAllThreeDatabases() { + assertEquals(3, Constants.AVAILABLE_KEYS.size()); + assertTrue(Constants.AVAILABLE_KEYS.contains(Constants.KEYDB_G4_CGM)); + assertTrue(Constants.AVAILABLE_KEYS.contains(Constants.KEYDB_PUMP_EXTRACTED)); + assertTrue(Constants.AVAILABLE_KEYS.contains(Constants.KEYDB_PUMP_HARDCODED)); + } +} From 7ab1262ee05daaad6b731d996da25ef579f68796 Mon Sep 17 00:00:00 2001 From: jlengelbrecht Date: Thu, 14 May 2026 02:41:13 -0400 Subject: [PATCH 08/10] Add GitHub Actions workflow for build and test Runs on every pull request and on manual workflow_dispatch. Single Ubuntu job that checks out the source, installs Temurin JDK 17, sets up Gradle with built-in caching, and runs the full build (which includes the unit test suite). On failure the HTML test reports are uploaded as an artifact for seven days so contributors can pull them down and inspect the exact failure without having to reproduce locally. --- .github/workflows/build.yml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..3fe6277 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,32 @@ +name: build + +on: + pull_request: + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Build and test + run: ./gradlew build --no-daemon + + - name: Upload test reports on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-reports + path: lib/build/reports/tests/test/ + retention-days: 7 From 705b6e5d73316775e39e8b1406256b11c83d638c Mon Sep 17 00:00:00 2001 From: jlengelbrecht Date: Thu, 14 May 2026 02:43:20 -0400 Subject: [PATCH 09/10] Add Spotless with google-java-format AOSP style and canonicalise sources Adds the com.diffplug.spotless plugin to the :lib subproject, wired via the version catalog. The Java formatter is google-java-format 1.22.0 with the AOSP variant (4-space indent, matching the existing style). spotlessCheck runs automatically as part of check (and so also as part of build), so the GitHub Actions workflow added in the previous commit will enforce style on every pull request. The remainder of the diff is a one-shot canonicalisation pass of every existing Java source file under the formatter. No behavioural changes; the full test suite still runs 58/58 green after the reformat. --- gradle/libs.versions.toml | 5 ++ lib/build.gradle.kts | 11 +++ .../java/org/openminimed/sake/Constants.java | 43 +++++------ .../java/org/openminimed/sake/DeviceType.java | 9 +-- .../org/openminimed/sake/KeyDatabase.java | 20 +++--- .../openminimed/sake/MacFailureException.java | 4 +- .../main/java/org/openminimed/sake/Peer.java | 5 +- .../java/org/openminimed/sake/RngSource.java | 13 ++-- .../java/org/openminimed/sake/SakeClient.java | 18 ++--- .../java/org/openminimed/sake/SakeServer.java | 38 +++++----- .../sake/SecureRandomRngSource.java | 4 +- .../java/org/openminimed/sake/SeqCrypt.java | 19 +++-- .../java/org/openminimed/sake/Session.java | 55 +++++++------- .../java/org/openminimed/sake/StaticKeys.java | 38 +++++----- .../org/openminimed/sake/crypto/AesCmac.java | 12 ++-- .../org/openminimed/sake/crypto/AesCtr.java | 20 +++--- .../org/openminimed/sake/crypto/AesEcb.java | 10 ++- .../openminimed/sake/crypto/package-info.java | 4 +- .../org/openminimed/sake/package-info.java | 4 +- .../org/openminimed/sake/ConstantsTest.java | 12 ++-- .../org/openminimed/sake/DeviceTypeTest.java | 4 +- .../test/java/org/openminimed/sake/Hex.java | 5 +- .../org/openminimed/sake/KeyDatabaseTest.java | 30 ++++---- .../org/openminimed/sake/QueuedRngSource.java | 10 +-- .../org/openminimed/sake/SakeClientTest.java | 24 +++---- .../org/openminimed/sake/SakeServerTest.java | 72 +++++++++---------- .../org/openminimed/sake/SeqCryptTest.java | 38 +++++----- .../org/openminimed/sake/SessionTest.java | 38 +++++----- .../openminimed/sake/crypto/AesCmacTest.java | 30 ++++---- .../openminimed/sake/crypto/AesCtrTest.java | 42 +++++------ .../openminimed/sake/crypto/AesEcbTest.java | 17 ++--- .../org/openminimed/sake/package-info.java | 4 +- 32 files changed, 337 insertions(+), 321 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cd8953d..68400cb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,9 +1,14 @@ [versions] junit = "5.11.3" bouncycastle = "1.79" +spotless = "6.25.0" +googleJavaFormat = "1.22.0" [libraries] junit-jupiter-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit" } junit-jupiter-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junit" } junit-platform-launcher = { group = "org.junit.platform", name = "junit-platform-launcher" } bouncycastle = { group = "org.bouncycastle", name = "bcprov-jdk18on", version.ref = "bouncycastle" } + +[plugins] +spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } diff --git a/lib/build.gradle.kts b/lib/build.gradle.kts index c6ac9d4..92818a2 100644 --- a/lib/build.gradle.kts +++ b/lib/build.gradle.kts @@ -1,5 +1,6 @@ plugins { `java-library` + alias(libs.plugins.spotless) } group = "org.openminimed" @@ -29,3 +30,13 @@ tasks.test { events("passed", "skipped", "failed") } } + +spotless { + java { + target("src/**/*.java") + googleJavaFormat(libs.versions.googleJavaFormat.get()).aosp() + removeUnusedImports() + trimTrailingWhitespace() + endWithNewline() + } +} diff --git a/lib/src/main/java/org/openminimed/sake/Constants.java b/lib/src/main/java/org/openminimed/sake/Constants.java index 302baa3..0ac106d 100644 --- a/lib/src/main/java/org/openminimed/sake/Constants.java +++ b/lib/src/main/java/org/openminimed/sake/Constants.java @@ -6,36 +6,39 @@ /** * Pre-baked key databases used by SAKE peers. * - *

These are the three databases shipped with the reference Python - * implementation in {@code pysake/constants.py}. The hex strings are - * reproduced verbatim and parsed at class-init time.

+ *

These are the three databases shipped with the reference Python implementation in {@code + * pysake/constants.py}. The hex strings are reproduced verbatim and parsed at class-init time. */ public final class Constants { /** Glucose sensor (G4 family) key database. Display-side. */ - public static final KeyDatabase KEYDB_G4_CGM = parse( - "5fe5928308010230f0b50df613f2e429c8c5e8713854add1a69b837235a3e974" - + "304d8055ccb397838b90823c73236d6a83dcc9db3a2a939ff16145ca4169ef93" - + "a7fa39b20962b05e57413bff8b3d61fce0dfef2c43b326"); + public static final KeyDatabase KEYDB_G4_CGM = + parse( + "5fe5928308010230f0b50df613f2e429c8c5e8713854add1a69b837235a3e974" + + "304d8055ccb397838b90823c73236d6a83dcc9db3a2a939ff16145ca4169ef93" + + "a7fa39b20962b05e57413bff8b3d61fce0dfef2c43b326"); /** Insulin pump database recovered from real pump firmware. Mobile-app-side. */ - public static final KeyDatabase KEYDB_PUMP_EXTRACTED = parse( - "f75995e70401011bc1bf7cbf36fa1e2367d795ff09211903da6afbe986b650f1" - + "4179c0e6852e0ce393781078ffc6f51919e2eaefbde69b8eca21e41ab59b881a" - + "0bea0286ea91dc7582a86a714e1737f558f0d66dc1895c"); + public static final KeyDatabase KEYDB_PUMP_EXTRACTED = + parse( + "f75995e70401011bc1bf7cbf36fa1e2367d795ff09211903da6afbe986b650f1" + + "4179c0e6852e0ce393781078ffc6f51919e2eaefbde69b8eca21e41ab59b881a" + + "0bea0286ea91dc7582a86a714e1737f558f0d66dc1895c"); /** Insulin pump database with hard-coded test keys. Mobile-app-side. */ - public static final KeyDatabase KEYDB_PUMP_HARDCODED = parse( - "c2cdfdd1040101fce36ed66ef21def3b0763975494b239038ebe8606f79a9bf0" - + "0d9f11b6db04c7c0434787cbf00d5476289c22288e2105ae40e01391837f9476" - + "fa5003895c5a1afe35662a2a6211826af016eebe30e4ba"); + public static final KeyDatabase KEYDB_PUMP_HARDCODED = + parse( + "c2cdfdd1040101fce36ed66ef21def3b0763975494b239038ebe8606f79a9bf0" + + "0d9f11b6db04c7c0434787cbf00d5476289c22288e2105ae40e01391837f9476" + + "fa5003895c5a1afe35662a2a6211826af016eebe30e4ba"); /** Databases available for production handshakes. */ - public static final List AVAILABLE_KEYS = Collections.unmodifiableList( - java.util.Arrays.asList(KEYDB_G4_CGM, KEYDB_PUMP_EXTRACTED, KEYDB_PUMP_HARDCODED)); + public static final List AVAILABLE_KEYS = + Collections.unmodifiableList( + java.util.Arrays.asList( + KEYDB_G4_CGM, KEYDB_PUMP_EXTRACTED, KEYDB_PUMP_HARDCODED)); - private Constants() { - } + private Constants() {} private static KeyDatabase parse(String hex) { return KeyDatabase.fromBytes(decode(hex)); @@ -48,7 +51,7 @@ private static byte[] decode(String hex) { byte[] out = new byte[hex.length() / 2]; for (int i = 0; i < out.length; i++) { int high = Character.digit(hex.charAt(2 * i), 16); - int low = Character.digit(hex.charAt(2 * i + 1), 16); + int low = Character.digit(hex.charAt(2 * i + 1), 16); if (high < 0 || low < 0) { throw new IllegalArgumentException("Invalid hex character"); } diff --git a/lib/src/main/java/org/openminimed/sake/DeviceType.java b/lib/src/main/java/org/openminimed/sake/DeviceType.java index 59397f9..9e08abd 100644 --- a/lib/src/main/java/org/openminimed/sake/DeviceType.java +++ b/lib/src/main/java/org/openminimed/sake/DeviceType.java @@ -3,11 +3,10 @@ /** * Type of device participating in a SAKE handshake. * - *

The numeric values are wire-stable: they are serialized into the - * key-database header and into handshake messages.

+ *

The numeric values are wire-stable: they are serialized into the key-database header and into + * handshake messages. */ public enum DeviceType { - INSULIN_PUMP(0x1), GLUCOSE_SENSOR(0x2), BLOOD_GLUCOSE_METER(0x3), @@ -26,7 +25,9 @@ public enum DeviceType { this.value = value; } - /** @return the wire value (1 byte, unsigned). */ + /** + * @return the wire value (1 byte, unsigned). + */ public int value() { return value; } diff --git a/lib/src/main/java/org/openminimed/sake/KeyDatabase.java b/lib/src/main/java/org/openminimed/sake/KeyDatabase.java index 8a924c6..40a8c9b 100644 --- a/lib/src/main/java/org/openminimed/sake/KeyDatabase.java +++ b/lib/src/main/java/org/openminimed/sake/KeyDatabase.java @@ -11,7 +11,8 @@ /** * Database of static keys shared between one local device and any number of remote devices. * - *

The on-wire layout is:

+ *

The on-wire layout is: + * *

  *   [ 4 B CRC32 big-endian over everything that follows ]
  *   [ 1 B local device type ]
@@ -20,7 +21,7 @@
  * 
* *

Each entry following the header is therefore 81 bytes and the total serialized length is - * {@code 6 + 81 * n} bytes.

+ * {@code 6 + 81 * n} bytes. */ public final class KeyDatabase { @@ -32,9 +33,8 @@ public final class KeyDatabase { private final Map remoteDevices; private final byte[] crc; - public KeyDatabase(DeviceType localDeviceType, - Map remoteDevices, - byte[] crc) { + public KeyDatabase( + DeviceType localDeviceType, Map remoteDevices, byte[] crc) { this.localDeviceType = Objects.requireNonNull(localDeviceType, "localDeviceType"); Objects.requireNonNull(remoteDevices, "remoteDevices"); Objects.requireNonNull(crc, "crc"); @@ -64,8 +64,8 @@ public static KeyDatabase fromBytes(byte[] data) { byte[] computedCrc = computeCrc(payload); if (!java.util.Arrays.equals(storedCrc, computedCrc)) { - throw new IllegalArgumentException("CRC mismatch: stored=" - + hex(storedCrc) + " computed=" + hex(computedCrc)); + throw new IllegalArgumentException( + "CRC mismatch: stored=" + hex(storedCrc) + " computed=" + hex(computedCrc)); } DeviceType localDeviceType = DeviceType.fromValue(payload[0] & 0xFF); @@ -87,7 +87,9 @@ public static KeyDatabase fromBytes(byte[] data) { return new KeyDatabase(localDeviceType, remotes, storedCrc); } - /** @return the serialized database with a freshly computed CRC. */ + /** + * @return the serialized database with a freshly computed CRC. + */ public byte[] toBytes() { byte[] payload = payloadBytes(); byte[] crcBytes = computeCrc(payload); @@ -100,7 +102,7 @@ public byte[] toBytes() { /** * Return a new database with the local/remote roles swapped. * - *

Requires exactly one remote device.

+ *

Requires exactly one remote device. * * @throws IllegalStateException if this database does not contain exactly one remote device. */ diff --git a/lib/src/main/java/org/openminimed/sake/MacFailureException.java b/lib/src/main/java/org/openminimed/sake/MacFailureException.java index 85560f5..1fd9f11 100644 --- a/lib/src/main/java/org/openminimed/sake/MacFailureException.java +++ b/lib/src/main/java/org/openminimed/sake/MacFailureException.java @@ -1,8 +1,6 @@ package org.openminimed.sake; -/** - * Thrown when a CMAC trailer does not match the computed value during decryption. - */ +/** Thrown when a CMAC trailer does not match the computed value during decryption. */ public class MacFailureException extends Exception { private static final long serialVersionUID = 1L; diff --git a/lib/src/main/java/org/openminimed/sake/Peer.java b/lib/src/main/java/org/openminimed/sake/Peer.java index 1e4c341..f1c92e1 100644 --- a/lib/src/main/java/org/openminimed/sake/Peer.java +++ b/lib/src/main/java/org/openminimed/sake/Peer.java @@ -3,8 +3,9 @@ /** * Base class for {@link SakeServer} and {@link SakeClient}: tracks the handshake stage. * - *

Stage progression for a server: 0 → 1 → 3 → 5 → 6.

- *

Stage progression for a client: 0 → 2 → 4 → 6.

+ *

Stage progression for a server: 0 → 1 → 3 → 5 → 6. + * + *

Stage progression for a client: 0 → 2 → 4 → 6. */ public abstract class Peer { diff --git a/lib/src/main/java/org/openminimed/sake/RngSource.java b/lib/src/main/java/org/openminimed/sake/RngSource.java index 7c91cfc..a120935 100644 --- a/lib/src/main/java/org/openminimed/sake/RngSource.java +++ b/lib/src/main/java/org/openminimed/sake/RngSource.java @@ -1,15 +1,16 @@ package org.openminimed.sake; /** - * Source of random bytes used to populate handshake fields the server / client - * are expected to choose freshly per session. + * Source of random bytes used to populate handshake fields the server / client are expected to + * choose freshly per session. * - *

The default implementation is backed by {@link java.security.SecureRandom}. - * Tests can substitute a deterministic source to drive a server or client - * against a captured packet trace.

+ *

The default implementation is backed by {@link java.security.SecureRandom}. Tests can + * substitute a deterministic source to drive a server or client against a captured packet trace. */ public interface RngSource { - /** @return {@code n} fresh random bytes. */ + /** + * @return {@code n} fresh random bytes. + */ byte[] nextBytes(int n); } diff --git a/lib/src/main/java/org/openminimed/sake/SakeClient.java b/lib/src/main/java/org/openminimed/sake/SakeClient.java index d34c2f6..a6417ca 100644 --- a/lib/src/main/java/org/openminimed/sake/SakeClient.java +++ b/lib/src/main/java/org/openminimed/sake/SakeClient.java @@ -1,15 +1,14 @@ package org.openminimed.sake; -import org.openminimed.sake.crypto.AesCmac; - import java.util.Arrays; import java.util.Objects; +import org.openminimed.sake.crypto.AesCmac; /** * Client-side wrapper around {@link Session}. * - *

Stage progression: 0 (consume server msg0, emit msg1) → 2 (consume msg2, - * emit msg3) → 4 (consume msg4, emit msg5) → 6 (done).

+ *

Stage progression: 0 (consume server msg0, emit msg1) → 2 (consume msg2, emit msg3) → 4 + * (consume msg4, emit msg5) → 6 (done). */ public final class SakeClient extends Peer { @@ -109,11 +108,12 @@ private byte[] buildHandshake1C() { } private byte[] buildHandshake3C() { - AesCmac auth1 = Session.cmac8( - session.clientKeyMaterial(), - session.serverKeyMaterial(), - session.derivationKey(), - session.handshakeAuthKey()); + AesCmac auth1 = + Session.cmac8( + session.clientKeyMaterial(), + session.serverKeyMaterial(), + session.derivationKey(), + session.handshakeAuthKey()); byte[] auth1Tag = auth1.digest(); byte[] inner = new byte[8 + 8 + 16]; diff --git a/lib/src/main/java/org/openminimed/sake/SakeServer.java b/lib/src/main/java/org/openminimed/sake/SakeServer.java index a01b605..398d642 100644 --- a/lib/src/main/java/org/openminimed/sake/SakeServer.java +++ b/lib/src/main/java/org/openminimed/sake/SakeServer.java @@ -1,17 +1,15 @@ package org.openminimed.sake; -import org.openminimed.sake.crypto.AesCmac; - import java.util.Arrays; import java.util.Objects; +import org.openminimed.sake.crypto.AesCmac; /** - * Server-side wrapper around {@link Session}: drives the handshake state machine - * and builds outgoing messages from the running state. + * Server-side wrapper around {@link Session}: drives the handshake state machine and builds + * outgoing messages from the running state. * - *

Stage progression: 0 (consume 20 zero bytes, emit msg0) → 1 (consume msg1, - * emit msg2) → 3 (consume msg3, emit msg4) → 5 (consume msg5, emit {@code null} - * to signal completion) → 6 (done).

+ *

Stage progression: 0 (consume 20 zero bytes, emit msg0) → 1 (consume msg1, emit msg2) → 3 + * (consume msg3, emit msg4) → 5 (consume msg5, emit {@code null} to signal completion) → 6 (done). */ public final class SakeServer extends Peer { @@ -43,12 +41,11 @@ public SakeServer(KeyDatabase keyDb, DeviceType localDeviceType, RngSource rng) /** * Drive the handshake one step. * - * @param input the 20-byte message just received from the client. At stage 0 - * this must be 20 zero bytes (the wake-up frame sent over the - * SAKE characteristic when the peripheral subscribes to - * notifications). - * @return the 20-byte message to send back to the client, or {@code null} once - * the handshake completes at stage 5. + * @param input the 20-byte message just received from the client. At stage 0 this must be 20 + * zero bytes (the wake-up frame sent over the SAKE characteristic when the peripheral + * subscribes to notifications). + * @return the 20-byte message to send back to the client, or {@code null} once the handshake + * completes at stage 5. * @throws MacFailureException if any CMAC verification fails. */ public byte[] handshake(byte[] input) throws MacFailureException { @@ -111,8 +108,8 @@ public DeviceType localDeviceType() { } /** - * Override the msg4 pad byte. Package-private; only the parity tests use this - * to reproduce a captured packet trace bit-for-bit. + * Override the msg4 pad byte. Package-private; only the parity tests use this to reproduce a + * captured packet trace bit-for-bit. */ void setMsg4Pad(byte value) { this.msg4Pad = value; @@ -130,11 +127,12 @@ private byte[] buildHandshake0S() { private byte[] buildHandshake2S() { byte[] serverKm = rng.nextBytes(8); byte[] serverNonce = rng.nextBytes(4); - AesCmac auth = Session.cmac8( - session.clientKeyMaterial(), - serverKm, - session.derivationKey(), - session.handshakeAuthKey()); + AesCmac auth = + Session.cmac8( + session.clientKeyMaterial(), + serverKm, + session.derivationKey(), + session.handshakeAuthKey()); byte[] prefix = auth.digest(); byte[] out = new byte[Session.MESSAGE_SIZE]; diff --git a/lib/src/main/java/org/openminimed/sake/SecureRandomRngSource.java b/lib/src/main/java/org/openminimed/sake/SecureRandomRngSource.java index 6f1456a..673a996 100644 --- a/lib/src/main/java/org/openminimed/sake/SecureRandomRngSource.java +++ b/lib/src/main/java/org/openminimed/sake/SecureRandomRngSource.java @@ -2,9 +2,7 @@ import java.security.SecureRandom; -/** - * Default {@link RngSource} backed by {@link SecureRandom}. - */ +/** Default {@link RngSource} backed by {@link SecureRandom}. */ public final class SecureRandomRngSource implements RngSource { private final SecureRandom rng = new SecureRandom(); diff --git a/lib/src/main/java/org/openminimed/sake/SeqCrypt.java b/lib/src/main/java/org/openminimed/sake/SeqCrypt.java index a582b1c..53cab41 100644 --- a/lib/src/main/java/org/openminimed/sake/SeqCrypt.java +++ b/lib/src/main/java/org/openminimed/sake/SeqCrypt.java @@ -1,25 +1,23 @@ package org.openminimed.sake; -import org.openminimed.sake.crypto.AesCmac; -import org.openminimed.sake.crypto.AesCtr; - import java.util.Arrays; import java.util.Objects; +import org.openminimed.sake.crypto.AesCmac; +import org.openminimed.sake.crypto.AesCtr; /** * Sequence-numbered AES-CTR stream cipher with a CMAC trailer. * - *

Each direction of the SAKE session is one {@code SeqCrypt} instance with - * its own sequence counter. The encrypted form of a message is - * {@code ciphertext || trailer} where {@code trailer} is three bytes:

+ *

Each direction of the SAKE session is one {@code SeqCrypt} instance with its own sequence + * counter. The encrypted form of a message is {@code ciphertext || trailer} where {@code trailer} + * is three bytes: * *

  *   [ (seq >> 1) & 0xFF ][ CMAC4(nonce.padTo16 || ciphertext)[0..2] ]
  * 
* - *

The receiver reconstructs the full 32-bit sequence from its local - * {@code rxSeq} and the 1-byte field in the trailer, tolerating an 8-bit - * wrap-around.

+ *

The receiver reconstructs the full 32-bit sequence from its local {@code rxSeq} and the 1-byte + * field in the trailer, tolerating an 8-bit wrap-around. */ public final class SeqCrypt { @@ -88,8 +86,7 @@ public byte[] decrypt(byte[] message) throws MacFailureException { if ((tagPrefix[0] != message[ciphertextLen + 1]) || (tagPrefix[1] != message[ciphertextLen + 2])) { - throw new MacFailureException( - "MAC verification failed at seq=" + seq); + throw new MacFailureException("MAC verification failed at seq=" + seq); } byte[] iv = buildIv(seq); diff --git a/lib/src/main/java/org/openminimed/sake/Session.java b/lib/src/main/java/org/openminimed/sake/Session.java index 08c8be0..b7ec8e5 100644 --- a/lib/src/main/java/org/openminimed/sake/Session.java +++ b/lib/src/main/java/org/openminimed/sake/Session.java @@ -1,23 +1,20 @@ package org.openminimed.sake; -import org.openminimed.sake.crypto.AesCmac; -import org.openminimed.sake.crypto.AesEcb; - import java.util.Arrays; import java.util.Objects; +import org.openminimed.sake.crypto.AesCmac; +import org.openminimed.sake.crypto.AesEcb; /** * Holds the cryptographic state of a single SAKE handshake. * - *

Exactly one of {@code clientKeyDb} or {@code serverKeyDb} must be supplied - * at construction time: the absent side will be unable to perform the - * cryptographic permit check on its inbound permit message and will simply log - * the payload comparison instead.

+ *

Exactly one of {@code clientKeyDb} or {@code serverKeyDb} must be supplied at construction + * time: the absent side will be unable to perform the cryptographic permit check on its inbound + * permit message and will simply log the payload comparison instead. * - *

Method names match the reference Python implementation - * ({@code pysake/session.py}). The trailing {@code S} / {@code C} marks which - * side the message originated from (server vs client), not which side is - * calling the method.

+ *

Method names match the reference Python implementation ({@code pysake/session.py}). The + * trailing {@code S} / {@code C} marks which side the message originated from (server vs client), + * not which side is calling the method. */ public final class Session { @@ -88,8 +85,10 @@ public void handshake1C(byte[] msg) { } if (staticKeys == null) { throw new IllegalStateException( - "No keys available for client device type " + cdt - + " and server device type " + sdt); + "No keys available for client device type " + + cdt + + " and server device type " + + sdt); } this.derivationKey = staticKeys.derivationKey(); @@ -100,7 +99,7 @@ public void handshake2S(byte[] msg) throws MacFailureException { checkLen(msg); byte[] received = Arrays.copyOfRange(msg, 0, CMAC8_SIZE); byte[] serverKm = Arrays.copyOfRange(msg, 8, 16); - byte[] serverN = Arrays.copyOfRange(msg, 16, 20); + byte[] serverN = Arrays.copyOfRange(msg, 16, 20); AesCmac auth = cmac8(clientKeyMaterial, serverKm, derivationKey, handshakeAuthKey); if (!auth.verify(received)) { @@ -115,13 +114,15 @@ public void handshake3C(byte[] msg) throws MacFailureException { checkLen(msg); byte[] received = Arrays.copyOfRange(msg, 0, CMAC8_SIZE); - AesCmac auth1 = cmac8(clientKeyMaterial, serverKeyMaterial, derivationKey, handshakeAuthKey); + AesCmac auth1 = + cmac8(clientKeyMaterial, serverKeyMaterial, derivationKey, handshakeAuthKey); byte[] auth1Tag = auth1.digest(); byte[] inner = new byte[CMAC8_SIZE + KEY_MATERIAL_SIZE + AesCmac.BLOCK_SIZE]; System.arraycopy(auth1Tag, 0, inner, 0, CMAC8_SIZE); System.arraycopy(serverKeyMaterial, 0, inner, CMAC8_SIZE, KEY_MATERIAL_SIZE); - System.arraycopy(derivationKey, 0, inner, CMAC8_SIZE + KEY_MATERIAL_SIZE, AesCmac.BLOCK_SIZE); + System.arraycopy( + derivationKey, 0, inner, CMAC8_SIZE + KEY_MATERIAL_SIZE, AesCmac.BLOCK_SIZE); AesCmac auth2 = new AesCmac(handshakeAuthKey, CMAC8_SIZE); auth2.update(inner); @@ -150,11 +151,11 @@ public boolean handshake5C(byte[] msg) throws MacFailureException { /** * Compute the 8-byte CMAC over {@code serverKm || clientKm || derivationKey}. * - * @return a primed {@link AesCmac} ready to {@link AesCmac#digest()} or - * {@link AesCmac#verify(byte[])}. + * @return a primed {@link AesCmac} ready to {@link AesCmac#digest()} or {@link + * AesCmac#verify(byte[])}. */ - public static AesCmac cmac8(byte[] clientKm, byte[] serverKm, - byte[] derivationKey, byte[] handshakeAuthKey) { + public static AesCmac cmac8( + byte[] clientKm, byte[] serverKm, byte[] derivationKey, byte[] handshakeAuthKey) { byte[] msg = new byte[32]; System.arraycopy(serverKm, 0, msg, 0, KEY_MATERIAL_SIZE); System.arraycopy(clientKm, 0, msg, KEY_MATERIAL_SIZE, KEY_MATERIAL_SIZE); @@ -164,7 +165,9 @@ public static AesCmac cmac8(byte[] clientKm, byte[] serverKm, return cmac; } - /** @throws IllegalArgumentException if {@code msg} is not exactly {@value #MESSAGE_SIZE} bytes. */ + /** + * @throws IllegalArgumentException if {@code msg} is not exactly {@value #MESSAGE_SIZE} bytes. + */ public static void checkLen(byte[] msg) { Objects.requireNonNull(msg, "msg"); if (msg.length != MESSAGE_SIZE) { @@ -187,10 +190,12 @@ private void createCrypts() { this.serverCrypt = new SeqCrypt(sessionKey, sessionNonce, 1L); } - private boolean checkPermit(byte[] payload, - StaticKeys verifierStaticKeys, - StaticKeys proverStaticKeys, - int proverDeviceType) throws MacFailureException { + private boolean checkPermit( + byte[] payload, + StaticKeys verifierStaticKeys, + StaticKeys proverStaticKeys, + int proverDeviceType) + throws MacFailureException { if (verifierStaticKeys == null) { return false; } diff --git a/lib/src/main/java/org/openminimed/sake/StaticKeys.java b/lib/src/main/java/org/openminimed/sake/StaticKeys.java index f6f99d1..b9a0838 100644 --- a/lib/src/main/java/org/openminimed/sake/StaticKeys.java +++ b/lib/src/main/java/org/openminimed/sake/StaticKeys.java @@ -4,16 +4,16 @@ import java.util.Objects; /** - * Five 16-byte keys shared between two specific devices, plus an opaque - * 16-byte handshake payload. + * Five 16-byte keys shared between two specific devices, plus an opaque 16-byte handshake payload. + * + *

The on-wire form is the concatenation, in declaration order, of: * - *

The on-wire form is the concatenation, in declaration order, of:

*
    - *
  1. {@code derivationKey}
  2. - *
  3. {@code handshakeAuthKey}
  4. - *
  5. {@code permitDecryptKey}
  6. - *
  7. {@code permitAuthKey}
  8. - *
  9. {@code handshakePayload}
  10. + *
  11. {@code derivationKey} + *
  12. {@code handshakeAuthKey} + *
  13. {@code permitDecryptKey} + *
  14. {@code permitAuthKey} + *
  15. {@code handshakePayload} *
*/ public final class StaticKeys { @@ -30,11 +30,12 @@ public final class StaticKeys { private final byte[] permitAuthKey; private final byte[] handshakePayload; - public StaticKeys(byte[] derivationKey, - byte[] handshakeAuthKey, - byte[] permitDecryptKey, - byte[] permitAuthKey, - byte[] handshakePayload) { + public StaticKeys( + byte[] derivationKey, + byte[] handshakeAuthKey, + byte[] permitDecryptKey, + byte[] permitAuthKey, + byte[] handshakePayload) { this.derivationKey = requireExactSize("derivationKey", derivationKey); this.handshakeAuthKey = requireExactSize("handshakeAuthKey", handshakeAuthKey); this.permitDecryptKey = requireExactSize("permitDecryptKey", permitDecryptKey); @@ -45,7 +46,8 @@ public StaticKeys(byte[] derivationKey, /** * Parse a {@link StaticKeys} from exactly {@value #SERIALIZED_SIZE} bytes. * - * @throws IllegalArgumentException if the buffer is not exactly {@value #SERIALIZED_SIZE} bytes. + * @throws IllegalArgumentException if the buffer is not exactly {@value #SERIALIZED_SIZE} + * bytes. */ public static StaticKeys fromBytes(byte[] data) { Objects.requireNonNull(data, "data"); @@ -61,13 +63,15 @@ public static StaticKeys fromBytes(byte[] data) { Arrays.copyOfRange(data, 4 * FIELD_SIZE, 5 * FIELD_SIZE)); } - /** @return a new {@value #SERIALIZED_SIZE}-byte buffer containing the serialized form. */ + /** + * @return a new {@value #SERIALIZED_SIZE}-byte buffer containing the serialized form. + */ public byte[] toBytes() { byte[] out = new byte[SERIALIZED_SIZE]; - System.arraycopy(derivationKey, 0, out, 0 * FIELD_SIZE, FIELD_SIZE); + System.arraycopy(derivationKey, 0, out, 0 * FIELD_SIZE, FIELD_SIZE); System.arraycopy(handshakeAuthKey, 0, out, 1 * FIELD_SIZE, FIELD_SIZE); System.arraycopy(permitDecryptKey, 0, out, 2 * FIELD_SIZE, FIELD_SIZE); - System.arraycopy(permitAuthKey, 0, out, 3 * FIELD_SIZE, FIELD_SIZE); + System.arraycopy(permitAuthKey, 0, out, 3 * FIELD_SIZE, FIELD_SIZE); System.arraycopy(handshakePayload, 0, out, 4 * FIELD_SIZE, FIELD_SIZE); return out; } diff --git a/lib/src/main/java/org/openminimed/sake/crypto/AesCmac.java b/lib/src/main/java/org/openminimed/sake/crypto/AesCmac.java index d9c2edc..6f6dde0 100644 --- a/lib/src/main/java/org/openminimed/sake/crypto/AesCmac.java +++ b/lib/src/main/java/org/openminimed/sake/crypto/AesCmac.java @@ -1,19 +1,17 @@ package org.openminimed.sake.crypto; +import java.util.Arrays; +import java.util.Objects; import org.bouncycastle.crypto.engines.AESEngine; import org.bouncycastle.crypto.macs.CMac; import org.bouncycastle.crypto.params.KeyParameter; -import java.util.Arrays; -import java.util.Objects; - /** * Stateful AES-CMAC over a 16-byte AES-128 key with a configurable truncation length. * - *

The handshake uses both {@code macLen=8} (handshake CMAC chain) and - * {@code macLen=4} (permit auth, SeqCrypt trailer prefix). BouncyCastle always - * produces a 16-byte tag; we truncate to {@code macLen} bytes to match the - * PyCryptodome {@code mac_len} parameter.

+ *

The handshake uses both {@code macLen=8} (handshake CMAC chain) and {@code macLen=4} (permit + * auth, SeqCrypt trailer prefix). BouncyCastle always produces a 16-byte tag; we truncate to {@code + * macLen} bytes to match the PyCryptodome {@code mac_len} parameter. */ public final class AesCmac { diff --git a/lib/src/main/java/org/openminimed/sake/crypto/AesCtr.java b/lib/src/main/java/org/openminimed/sake/crypto/AesCtr.java index 9d45f22..b0a8156 100644 --- a/lib/src/main/java/org/openminimed/sake/crypto/AesCtr.java +++ b/lib/src/main/java/org/openminimed/sake/crypto/AesCtr.java @@ -1,27 +1,26 @@ package org.openminimed.sake.crypto; +import java.security.GeneralSecurityException; +import java.util.Objects; import javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; -import java.security.GeneralSecurityException; -import java.util.Objects; /** * AES-128 CTR stream encryption. * - *

The 16-byte IV is treated as the initial 128-bit counter and incremented - * by one per block. Callers are responsible for assembling the IV such that - * the counter region does not wrap into the nonce region.

+ *

The 16-byte IV is treated as the initial 128-bit counter and incremented by one per block. + * Callers are responsible for assembling the IV such that the counter region does not wrap into the + * nonce region. * - *

CTR is symmetric so the same method is used to encrypt and decrypt.

+ *

CTR is symmetric so the same method is used to encrypt and decrypt. */ public final class AesCtr { /** AES block size and IV length in bytes. */ public static final int BLOCK_SIZE = 16; - private AesCtr() { - } + private AesCtr() {} public static byte[] crypt(byte[] key, byte[] iv, byte[] data) { Objects.requireNonNull(key, "key"); @@ -35,9 +34,8 @@ public static byte[] crypt(byte[] key, byte[] iv, byte[] data) { } try { Cipher cipher = Cipher.getInstance("AES/CTR/NoPadding"); - cipher.init(Cipher.ENCRYPT_MODE, - new SecretKeySpec(key, "AES"), - new IvParameterSpec(iv)); + cipher.init( + Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), new IvParameterSpec(iv)); return cipher.doFinal(data); } catch (GeneralSecurityException e) { throw new IllegalStateException("AES/CTR/NoPadding unavailable", e); diff --git a/lib/src/main/java/org/openminimed/sake/crypto/AesEcb.java b/lib/src/main/java/org/openminimed/sake/crypto/AesEcb.java index 4c29ac3..0913440 100644 --- a/lib/src/main/java/org/openminimed/sake/crypto/AesEcb.java +++ b/lib/src/main/java/org/openminimed/sake/crypto/AesEcb.java @@ -1,23 +1,21 @@ package org.openminimed.sake.crypto; -import javax.crypto.Cipher; -import javax.crypto.spec.SecretKeySpec; import java.security.GeneralSecurityException; import java.util.Objects; +import javax.crypto.Cipher; +import javax.crypto.spec.SecretKeySpec; /** * Single-block AES-128 ECB encrypt / decrypt. * - *

Used by the handshake exclusively on 16-byte blocks; this class does not - * accept anything else.

+ *

Used by the handshake exclusively on 16-byte blocks; this class does not accept anything else. */ public final class AesEcb { /** AES block size in bytes. */ public static final int BLOCK_SIZE = 16; - private AesEcb() { - } + private AesEcb() {} public static byte[] encryptBlock(byte[] key, byte[] block) { return process(key, block, Cipher.ENCRYPT_MODE); diff --git a/lib/src/main/java/org/openminimed/sake/crypto/package-info.java b/lib/src/main/java/org/openminimed/sake/crypto/package-info.java index 9366173..936176d 100644 --- a/lib/src/main/java/org/openminimed/sake/crypto/package-info.java +++ b/lib/src/main/java/org/openminimed/sake/crypto/package-info.java @@ -1,7 +1,7 @@ /** * Thin AES primitive wrappers used by the SAKE handshake. * - *

AES-ECB and AES-CTR are served by the JDK's JCE provider. AES-CMAC is - * implemented via BouncyCastle as the JDK does not ship it.

+ *

AES-ECB and AES-CTR are served by the JDK's JCE provider. AES-CMAC is implemented via + * BouncyCastle as the JDK does not ship it. */ package org.openminimed.sake.crypto; diff --git a/lib/src/main/java/org/openminimed/sake/package-info.java b/lib/src/main/java/org/openminimed/sake/package-info.java index 78d746c..eb93c40 100644 --- a/lib/src/main/java/org/openminimed/sake/package-info.java +++ b/lib/src/main/java/org/openminimed/sake/package-info.java @@ -1,7 +1,7 @@ /** * Java port of the SAKE handshake protocol used by 700-series Medtronic pumps. * - *

This package mirrors the public surface of the reference Python - * implementation at PythonSake.

+ *

This package mirrors the public surface of the reference Python implementation at PythonSake. */ package org.openminimed.sake; diff --git a/lib/src/test/java/org/openminimed/sake/ConstantsTest.java b/lib/src/test/java/org/openminimed/sake/ConstantsTest.java index b3f0ac0..a655750 100644 --- a/lib/src/test/java/org/openminimed/sake/ConstantsTest.java +++ b/lib/src/test/java/org/openminimed/sake/ConstantsTest.java @@ -1,11 +1,11 @@ package org.openminimed.sake; -import org.junit.jupiter.api.Test; - import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.Test; + class ConstantsTest { @Test @@ -16,15 +16,15 @@ void g4CgmLocalIsPrimaryDisplay() { @Test void pumpExtractedLocalIsMobileApplication() { - assertEquals(DeviceType.MOBILE_APPLICATION, - Constants.KEYDB_PUMP_EXTRACTED.localDeviceType()); + assertEquals( + DeviceType.MOBILE_APPLICATION, Constants.KEYDB_PUMP_EXTRACTED.localDeviceType()); assertNotNull(Constants.KEYDB_PUMP_EXTRACTED.remoteDevices().get(DeviceType.INSULIN_PUMP)); } @Test void pumpHardcodedLocalIsMobileApplication() { - assertEquals(DeviceType.MOBILE_APPLICATION, - Constants.KEYDB_PUMP_HARDCODED.localDeviceType()); + assertEquals( + DeviceType.MOBILE_APPLICATION, Constants.KEYDB_PUMP_HARDCODED.localDeviceType()); assertNotNull(Constants.KEYDB_PUMP_HARDCODED.remoteDevices().get(DeviceType.INSULIN_PUMP)); } diff --git a/lib/src/test/java/org/openminimed/sake/DeviceTypeTest.java b/lib/src/test/java/org/openminimed/sake/DeviceTypeTest.java index c5d6978..4f8531a 100644 --- a/lib/src/test/java/org/openminimed/sake/DeviceTypeTest.java +++ b/lib/src/test/java/org/openminimed/sake/DeviceTypeTest.java @@ -1,11 +1,11 @@ package org.openminimed.sake; -import org.junit.jupiter.api.Test; - import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; +import org.junit.jupiter.api.Test; + class DeviceTypeTest { @Test diff --git a/lib/src/test/java/org/openminimed/sake/Hex.java b/lib/src/test/java/org/openminimed/sake/Hex.java index 523db1b..a1f3284 100644 --- a/lib/src/test/java/org/openminimed/sake/Hex.java +++ b/lib/src/test/java/org/openminimed/sake/Hex.java @@ -2,8 +2,7 @@ public final class Hex { - private Hex() { - } + private Hex() {} public static byte[] decode(String hex) { if ((hex.length() & 1) != 0) { @@ -12,7 +11,7 @@ public static byte[] decode(String hex) { byte[] out = new byte[hex.length() / 2]; for (int i = 0; i < out.length; i++) { int high = Character.digit(hex.charAt(2 * i), 16); - int low = Character.digit(hex.charAt(2 * i + 1), 16); + int low = Character.digit(hex.charAt(2 * i + 1), 16); if (high < 0 || low < 0) { throw new IllegalArgumentException("Invalid hex character at index " + (2 * i)); } diff --git a/lib/src/test/java/org/openminimed/sake/KeyDatabaseTest.java b/lib/src/test/java/org/openminimed/sake/KeyDatabaseTest.java index 79411c9..407a2f7 100644 --- a/lib/src/test/java/org/openminimed/sake/KeyDatabaseTest.java +++ b/lib/src/test/java/org/openminimed/sake/KeyDatabaseTest.java @@ -1,33 +1,33 @@ package org.openminimed.sake; -import org.junit.jupiter.api.Test; - import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import org.junit.jupiter.api.Test; + class KeyDatabaseTest { /** * The three baked-in key databases from the reference Python implementation - * (pysake/constants.py). They are the canonical round-trip vectors for this - * parser: any change to the serialization must keep these byte-identical. + * (pysake/constants.py). They are the canonical round-trip vectors for this parser: any change + * to the serialization must keep these byte-identical. */ private static final String HEX_G4_CGM = "5fe5928308010230f0b50df613f2e429c8c5e8713854add1a69b837235a3e974" - + "304d8055ccb397838b90823c73236d6a83dcc9db3a2a939ff16145ca4169ef93" - + "a7fa39b20962b05e57413bff8b3d61fce0dfef2c43b326"; + + "304d8055ccb397838b90823c73236d6a83dcc9db3a2a939ff16145ca4169ef93" + + "a7fa39b20962b05e57413bff8b3d61fce0dfef2c43b326"; private static final String HEX_PUMP_EXTRACTED = "f75995e70401011bc1bf7cbf36fa1e2367d795ff09211903da6afbe986b650f1" - + "4179c0e6852e0ce393781078ffc6f51919e2eaefbde69b8eca21e41ab59b881a" - + "0bea0286ea91dc7582a86a714e1737f558f0d66dc1895c"; + + "4179c0e6852e0ce393781078ffc6f51919e2eaefbde69b8eca21e41ab59b881a" + + "0bea0286ea91dc7582a86a714e1737f558f0d66dc1895c"; private static final String HEX_PUMP_HARDCODED = "c2cdfdd1040101fce36ed66ef21def3b0763975494b239038ebe8606f79a9bf0" - + "0d9f11b6db04c7c0434787cbf00d5476289c22288e2105ae40e01391837f9476" - + "fa5003895c5a1afe35662a2a6211826af016eebe30e4ba"; + + "0d9f11b6db04c7c0434787cbf00d5476289c22288e2105ae40e01391837f9476" + + "fa5003895c5a1afe35662a2a6211826af016eebe30e4ba"; @Test void parsesG4Cgm() { @@ -55,10 +55,12 @@ void parsesPumpHardcoded() { @Test void roundTripIsByteIdentical() { - for (String hex : new String[]{HEX_G4_CGM, HEX_PUMP_EXTRACTED, HEX_PUMP_HARDCODED}) { + for (String hex : new String[] {HEX_G4_CGM, HEX_PUMP_EXTRACTED, HEX_PUMP_HARDCODED}) { byte[] original = Hex.decode(hex); byte[] roundTripped = KeyDatabase.fromBytes(original).toBytes(); - assertArrayEquals(original, roundTripped, + assertArrayEquals( + original, + roundTripped, "Round trip differed for " + hex.substring(0, 16) + "..."); } } @@ -72,13 +74,13 @@ void rejectsCrcMismatch() { @Test void rejectsTruncatedBuffer() { - byte[] truncated = new byte[]{0x00, 0x00, 0x00, 0x00, 0x04}; + byte[] truncated = new byte[] {0x00, 0x00, 0x00, 0x00, 0x04}; assertThrows(IllegalArgumentException.class, () -> KeyDatabase.fromBytes(truncated)); } @Test void reverseProducesValidDatabase() { - for (String hex : new String[]{HEX_G4_CGM, HEX_PUMP_EXTRACTED, HEX_PUMP_HARDCODED}) { + for (String hex : new String[] {HEX_G4_CGM, HEX_PUMP_EXTRACTED, HEX_PUMP_HARDCODED}) { KeyDatabase original = KeyDatabase.fromBytes(Hex.decode(hex)); KeyDatabase reversed = original.reverse(); assertEquals( diff --git a/lib/src/test/java/org/openminimed/sake/QueuedRngSource.java b/lib/src/test/java/org/openminimed/sake/QueuedRngSource.java index 1ad7587..99a2533 100644 --- a/lib/src/test/java/org/openminimed/sake/QueuedRngSource.java +++ b/lib/src/test/java/org/openminimed/sake/QueuedRngSource.java @@ -7,8 +7,8 @@ /** * Deterministic {@link RngSource} that returns pre-queued byte arrays in order. * - *

Used by parity tests to drive {@link SakeServer} / {@link SakeClient} - * through a captured packet trace.

+ *

Used by parity tests to drive {@link SakeServer} / {@link SakeClient} through a captured + * packet trace. */ final class QueuedRngSource implements RngSource { @@ -27,8 +27,10 @@ public byte[] nextBytes(int n) { } if (next.length != n) { throw new IllegalStateException( - "QueuedRngSource size mismatch: caller asked for " + n - + " bytes but next queued value is " + next.length); + "QueuedRngSource size mismatch: caller asked for " + + n + + " bytes but next queued value is " + + next.length); } return next.clone(); } diff --git a/lib/src/test/java/org/openminimed/sake/SakeClientTest.java b/lib/src/test/java/org/openminimed/sake/SakeClientTest.java index e6a54ce..3fa354b 100644 --- a/lib/src/test/java/org/openminimed/sake/SakeClientTest.java +++ b/lib/src/test/java/org/openminimed/sake/SakeClientTest.java @@ -1,32 +1,32 @@ package org.openminimed.sake; -import org.junit.jupiter.api.Test; - import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.Test; + /** - * End-to-end tests for {@link SakeClient}: driving a fresh client against a - * fresh server using a matched pair of custom key databases generated by - * {@code test_key_db_gen.py} in the reference Python implementation. + * End-to-end tests for {@link SakeClient}: driving a fresh client against a fresh server using a + * matched pair of custom key databases generated by {@code test_key_db_gen.py} in the reference + * Python implementation. * - *

The two databases share the same derivation key, handshake auth key and - * permit key set; together they form a complete two-sided test pair, the - * smallest configuration that lets both permit checks succeed.

+ *

The two databases share the same derivation key, handshake auth key and permit key set; + * together they form a complete two-sided test pair, the smallest configuration that lets both + * permit checks succeed. */ class SakeClientTest { private static final String KEYDB_CUSTOM_SERVER_HEX = "b079cdc504010144455249564154494f4e5f5f5f4b4559484e4453484b455f41" - + "5554485f4b455950484f4e455f5045524d49545f454e4350484f4e455f5045" - + "524d49545f4d4143ad14ad2780437db892d5650567d491b9"; + + "5554485f4b455950484f4e455f5045524d49545f454e4350484f4e455f5045" + + "524d49545f4d4143ad14ad2780437db892d5650567d491b9"; private static final String KEYDB_CUSTOM_CLIENT_HEX = "db8c1f2801010444455249564154494f4e5f5f5f4b4559484e4453484b455f41" - + "5554485f4b455950554d505f5045524d49545f454e435250554d505f504552" - + "4d49545f434d4143f2f8dbbb51563d4fa98fdaff0042a432"; + + "5554485f4b455950554d505f5045524d49545f454e435250554d505f504552" + + "4d49545f434d4143f2f8dbbb51563d4fa98fdaff0042a432"; private static KeyDatabase serverKeyDb() { return KeyDatabase.fromBytes(Hex.decode(KEYDB_CUSTOM_SERVER_HEX)); diff --git a/lib/src/test/java/org/openminimed/sake/SakeServerTest.java b/lib/src/test/java/org/openminimed/sake/SakeServerTest.java index fe45df7..05d83ca 100644 --- a/lib/src/test/java/org/openminimed/sake/SakeServerTest.java +++ b/lib/src/test/java/org/openminimed/sake/SakeServerTest.java @@ -1,57 +1,57 @@ package org.openminimed.sake; -import org.junit.jupiter.api.Test; - -import java.util.Arrays; - import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import java.util.Arrays; +import org.junit.jupiter.api.Test; + /** - * End-to-end parity tests for {@link SakeServer} against the captured 780G - * pairing pcap embedded in {@code pysake/constants.py} - * ({@code __PUMP_TEST_MSGS_1}). + * End-to-end parity tests for {@link SakeServer} against the captured 780G pairing pcap embedded in + * {@code pysake/constants.py} ({@code __PUMP_TEST_MSGS_1}). * - *

The server is driven with a deterministic {@link QueuedRngSource} that - * replays the random fields chosen during the original pcap (server msg0 - * filler, server key material, server nonce) and with the same {@code 0xf7} - * pad byte for msg4. Under those inputs the server must emit exactly the - * bytes recorded in the pcap.

+ *

The server is driven with a deterministic {@link QueuedRngSource} that replays the random + * fields chosen during the original pcap (server msg0 filler, server key material, server nonce) + * and with the same {@code 0xf7} pad byte for msg4. Under those inputs the server must emit exactly + * the bytes recorded in the pcap. */ class SakeServerTest { private static final String PUMP_KEYDB_HEX = "f75995e70401011bc1bf7cbf36fa1e2367d795ff09211903da6afbe986b650f1" - + "4179c0e6852e0ce393781078ffc6f51919e2eaefbde69b8eca21e41ab59b881a" - + "0bea0286ea91dc7582a86a714e1737f558f0d66dc1895c"; - - private static final byte[][] PUMP_TEST_MSGS = new byte[][]{ - Hex.decode("0401e2f09017a98f9f01cc56492fbacd4576e92b"), - Hex.decode("42060e9f344e9312016ee8854d357f659b6b00ba"), - Hex.decode("fdeeb13d04c3f18d272630ebeabe7c3a4d4d27b9"), - Hex.decode("c02cec4ffb99affcb553a10fa6c55bb13d9fbacf"), - Hex.decode("157d8e90214418a0e3d5f0517eebf4a82e00c02e"), - Hex.decode("9b36f393b296fa84a757809859fc84a5c300d59b"), - }; + + "4179c0e6852e0ce393781078ffc6f51919e2eaefbde69b8eca21e41ab59b881a" + + "0bea0286ea91dc7582a86a714e1737f558f0d66dc1895c"; + + private static final byte[][] PUMP_TEST_MSGS = + new byte[][] { + Hex.decode("0401e2f09017a98f9f01cc56492fbacd4576e92b"), + Hex.decode("42060e9f344e9312016ee8854d357f659b6b00ba"), + Hex.decode("fdeeb13d04c3f18d272630ebeabe7c3a4d4d27b9"), + Hex.decode("c02cec4ffb99affcb553a10fa6c55bb13d9fbacf"), + Hex.decode("157d8e90214418a0e3d5f0517eebf4a82e00c02e"), + Hex.decode("9b36f393b296fa84a757809859fc84a5c300d59b"), + }; /** - * Pad byte for the msg4 plaintext that reproduces the captured pcap. Recovered - * from the keystream as {@code captured[16] XOR keystream[16]} at tx_seq=1. + * Pad byte for the msg4 plaintext that reproduces the captured pcap. Recovered from the + * keystream as {@code captured[16] XOR keystream[16]} at tx_seq=1. */ private static final byte CAPTURED_MSG4_PAD = (byte) 0xf7; private static SakeServer captureMatchingServer() { - QueuedRngSource rng = new QueuedRngSource( - Arrays.copyOfRange(PUMP_TEST_MSGS[0], 2, 20), - Arrays.copyOfRange(PUMP_TEST_MSGS[2], 8, 16), - Arrays.copyOfRange(PUMP_TEST_MSGS[2], 16, 20)); - SakeServer server = new SakeServer( - KeyDatabase.fromBytes(Hex.decode(PUMP_KEYDB_HEX)), - DeviceType.MOBILE_APPLICATION, - rng); + QueuedRngSource rng = + new QueuedRngSource( + Arrays.copyOfRange(PUMP_TEST_MSGS[0], 2, 20), + Arrays.copyOfRange(PUMP_TEST_MSGS[2], 8, 16), + Arrays.copyOfRange(PUMP_TEST_MSGS[2], 16, 20)); + SakeServer server = + new SakeServer( + KeyDatabase.fromBytes(Hex.decode(PUMP_KEYDB_HEX)), + DeviceType.MOBILE_APPLICATION, + rng); server.setMsg4Pad(CAPTURED_MSG4_PAD); return server; } @@ -92,8 +92,7 @@ void msg0IsEmittedAtStageZero() throws Exception { @Test void stageZeroRejectsNonZeroInput() { - SakeServer server = new SakeServer( - KeyDatabase.fromBytes(Hex.decode(PUMP_KEYDB_HEX))); + SakeServer server = new SakeServer(KeyDatabase.fromBytes(Hex.decode(PUMP_KEYDB_HEX))); byte[] notZero = new byte[20]; notZero[5] = 0x01; assertThrows(IllegalArgumentException.class, () -> server.handshake(notZero)); @@ -101,8 +100,7 @@ void stageZeroRejectsNonZeroInput() { @Test void handshakeRejectsWrongLength() { - SakeServer server = new SakeServer( - KeyDatabase.fromBytes(Hex.decode(PUMP_KEYDB_HEX))); + SakeServer server = new SakeServer(KeyDatabase.fromBytes(Hex.decode(PUMP_KEYDB_HEX))); assertThrows(IllegalArgumentException.class, () -> server.handshake(new byte[19])); } diff --git a/lib/src/test/java/org/openminimed/sake/SeqCryptTest.java b/lib/src/test/java/org/openminimed/sake/SeqCryptTest.java index 6b045bd..d416f85 100644 --- a/lib/src/test/java/org/openminimed/sake/SeqCryptTest.java +++ b/lib/src/test/java/org/openminimed/sake/SeqCryptTest.java @@ -1,19 +1,17 @@ package org.openminimed.sake; -import org.junit.jupiter.api.Test; - import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import org.junit.jupiter.api.Test; + /** - * Parity tests against the reference Python implementation - * ({@code pysake/seqcrypt.py}). + * Parity tests against the reference Python implementation ({@code pysake/seqcrypt.py}). * - *

The captured ciphertexts below were generated by running the Python - * SeqCrypt with the same key, nonce and starting sequence values as the - * test setup here. Any divergence between Java and Python output for the - * same inputs would cause an assertion to fail.

+ *

The captured ciphertexts below were generated by running the Python SeqCrypt with the same + * key, nonce and starting sequence values as the test setup here. Any divergence between Java and + * Python output for the same inputs would cause an assertion to fail. */ class SeqCryptTest { @@ -24,10 +22,13 @@ class SeqCryptTest { private static final byte[] PLAIN_1 = Hex.decode("48656c6c6f2c2053414b65212121212121"); private static final byte[] PLAIN_2 = Hex.decode("deadbeef"); - private static final byte[] CIPHER_SEQ0_0 = Hex.decode("7680ea16798357df88b11466330e24d3f200b0e6"); - private static final byte[] CIPHER_SEQ0_1 = Hex.decode("9b92fdd551a0cbfc63b5fae41e888ca53c011265"); + private static final byte[] CIPHER_SEQ0_0 = + Hex.decode("7680ea16798357df88b11466330e24d3f200b0e6"); + private static final byte[] CIPHER_SEQ0_1 = + Hex.decode("9b92fdd551a0cbfc63b5fae41e888ca53c011265"); private static final byte[] CIPHER_SEQ0_2 = Hex.decode("60c26e3b02e1a3"); - private static final byte[] CIPHER_SEQ1 = Hex.decode("28b3469d0ece854f9760df0c7be2e79d93006a3d"); + private static final byte[] CIPHER_SEQ1 = + Hex.decode("28b3469d0ece854f9760df0c7be2e79d93006a3d"); @Test void encryptMatchesPythonAtSeqZero() { @@ -85,18 +86,19 @@ void encryptDecryptRoundTripIsInverse() throws Exception { } /** - * The 1-byte sequence field in the trailer wraps every 256 messages - * (since it is {@code (seq >>> 1) & 0xFF}). The receiver must reconstruct - * the full 32-bit sequence using its locally tracked {@code rxSeq} as the - * base and a delta in [0, 255]. This test exercises the wrap by sending a - * single message at {@code seq = 512} (trailer byte 0) to a receiver that - * still tracks {@code rxSeq = 510}. + * The 1-byte sequence field in the trailer wraps every 256 messages (since it is {@code (seq + * >>> 1) & 0xFF}). The receiver must reconstruct the full 32-bit sequence using its locally + * tracked {@code rxSeq} as the base and a delta in [0, 255]. This test exercises the wrap by + * sending a single message at {@code seq = 512} (trailer byte 0) to a receiver that still + * tracks {@code rxSeq = 510}. */ @Test void decryptHandlesEightBitSequenceWrap() throws Exception { SeqCrypt tx = new SeqCrypt(KEY, NONCE, 512L); byte[] cipher = tx.encrypt(PLAIN_1); - assertEquals(0, cipher[cipher.length - 3] & 0xFF, + assertEquals( + 0, + cipher[cipher.length - 3] & 0xFF, "trailer byte should be 0 at seq=512 (wrap-around point)"); SeqCrypt rx = new SeqCrypt(KEY, NONCE, 510L); diff --git a/lib/src/test/java/org/openminimed/sake/SessionTest.java b/lib/src/test/java/org/openminimed/sake/SessionTest.java index b9a5724..8d24553 100644 --- a/lib/src/test/java/org/openminimed/sake/SessionTest.java +++ b/lib/src/test/java/org/openminimed/sake/SessionTest.java @@ -1,7 +1,5 @@ package org.openminimed.sake; -import org.junit.jupiter.api.Test; - import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -9,30 +7,32 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.Test; + /** - * Parity tests for {@link Session} driven by the captured pump pairing pcap - * embedded in {@code pysake/constants.py} as {@code __PUMP_TEST_MSGS_1}. + * Parity tests for {@link Session} driven by the captured pump pairing pcap embedded in {@code + * pysake/constants.py} as {@code __PUMP_TEST_MSGS_1}. * - *

The expected per-checkpoint state was captured from a harness driving - * {@link Session} in the reference Python implementation against the same - * key database and message sequence. Any divergence in any derived field - * causes an assertion to fail and points at the responsible step.

+ *

The expected per-checkpoint state was captured from a harness driving {@link Session} in the + * reference Python implementation against the same key database and message sequence. Any + * divergence in any derived field causes an assertion to fail and points at the responsible step. */ class SessionTest { private static final String KEY_DB_HEX = "f75995e70401011bc1bf7cbf36fa1e2367d795ff09211903da6afbe986b650f1" - + "4179c0e6852e0ce393781078ffc6f51919e2eaefbde69b8eca21e41ab59b881a" - + "0bea0286ea91dc7582a86a714e1737f558f0d66dc1895c"; - - private static final byte[][] PUMP_TEST_MSGS = new byte[][]{ - Hex.decode("0401e2f09017a98f9f01cc56492fbacd4576e92b"), - Hex.decode("42060e9f344e9312016ee8854d357f659b6b00ba"), - Hex.decode("fdeeb13d04c3f18d272630ebeabe7c3a4d4d27b9"), - Hex.decode("c02cec4ffb99affcb553a10fa6c55bb13d9fbacf"), - Hex.decode("157d8e90214418a0e3d5f0517eebf4a82e00c02e"), - Hex.decode("9b36f393b296fa84a757809859fc84a5c300d59b"), - }; + + "4179c0e6852e0ce393781078ffc6f51919e2eaefbde69b8eca21e41ab59b881a" + + "0bea0286ea91dc7582a86a714e1737f558f0d66dc1895c"; + + private static final byte[][] PUMP_TEST_MSGS = + new byte[][] { + Hex.decode("0401e2f09017a98f9f01cc56492fbacd4576e92b"), + Hex.decode("42060e9f344e9312016ee8854d357f659b6b00ba"), + Hex.decode("fdeeb13d04c3f18d272630ebeabe7c3a4d4d27b9"), + Hex.decode("c02cec4ffb99affcb553a10fa6c55bb13d9fbacf"), + Hex.decode("157d8e90214418a0e3d5f0517eebf4a82e00c02e"), + Hex.decode("9b36f393b296fa84a757809859fc84a5c300d59b"), + }; private static KeyDatabase keyDb() { return KeyDatabase.fromBytes(Hex.decode(KEY_DB_HEX)); diff --git a/lib/src/test/java/org/openminimed/sake/crypto/AesCmacTest.java b/lib/src/test/java/org/openminimed/sake/crypto/AesCmacTest.java index 5c6dc46..2c6e9d5 100644 --- a/lib/src/test/java/org/openminimed/sake/crypto/AesCmacTest.java +++ b/lib/src/test/java/org/openminimed/sake/crypto/AesCmacTest.java @@ -1,16 +1,14 @@ package org.openminimed.sake.crypto; -import org.junit.jupiter.api.Test; -import org.openminimed.sake.Hex; - import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -/** - * Known-answer tests from RFC 4493 (AES-CMAC) Appendix. - */ +import org.junit.jupiter.api.Test; +import org.openminimed.sake.Hex; + +/** Known-answer tests from RFC 4493 (AES-CMAC) Appendix. */ class AesCmacTest { private static final byte[] KEY = Hex.decode("2b7e151628aed2a6abf7158809cf4f3c"); @@ -34,10 +32,11 @@ void rfc4493ExampleOneBlock() { @Test void rfc4493ExampleFortyBytes() { - byte[] msg = Hex.decode( - "6bc1bee22e409f96e93d7e117393172a" - + "ae2d8a571e03ac9c9eb76fac45af8e51" - + "30c81c46a35ce411"); + byte[] msg = + Hex.decode( + "6bc1bee22e409f96e93d7e117393172a" + + "ae2d8a571e03ac9c9eb76fac45af8e51" + + "30c81c46a35ce411"); byte[] expected = Hex.decode("dfa66747de9ae63030ca32611497c827"); AesCmac cmac = new AesCmac(KEY, 16); cmac.update(msg); @@ -46,11 +45,12 @@ void rfc4493ExampleFortyBytes() { @Test void rfc4493ExampleSixtyFourBytes() { - byte[] msg = Hex.decode( - "6bc1bee22e409f96e93d7e117393172a" - + "ae2d8a571e03ac9c9eb76fac45af8e51" - + "30c81c46a35ce411e5fbc1191a0a52ef" - + "f69f2445df4f9b17ad2b417be66c3710"); + byte[] msg = + Hex.decode( + "6bc1bee22e409f96e93d7e117393172a" + + "ae2d8a571e03ac9c9eb76fac45af8e51" + + "30c81c46a35ce411e5fbc1191a0a52ef" + + "f69f2445df4f9b17ad2b417be66c3710"); byte[] expected = Hex.decode("51f0bebf7e3b9d92fc49741779363cfe"); AesCmac cmac = new AesCmac(KEY, 16); cmac.update(msg); diff --git a/lib/src/test/java/org/openminimed/sake/crypto/AesCtrTest.java b/lib/src/test/java/org/openminimed/sake/crypto/AesCtrTest.java index abfaa21..d5ccaed 100644 --- a/lib/src/test/java/org/openminimed/sake/crypto/AesCtrTest.java +++ b/lib/src/test/java/org/openminimed/sake/crypto/AesCtrTest.java @@ -1,32 +1,31 @@ package org.openminimed.sake.crypto; -import org.junit.jupiter.api.Test; -import org.openminimed.sake.Hex; - import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertThrows; -/** - * Known-answer tests from NIST SP 800-38A Appendix F.5 (AES-128 CTR). - */ +import org.junit.jupiter.api.Test; +import org.openminimed.sake.Hex; + +/** Known-answer tests from NIST SP 800-38A Appendix F.5 (AES-128 CTR). */ class AesCtrTest { private static final byte[] KEY = Hex.decode("2b7e151628aed2a6abf7158809cf4f3c"); - private static final byte[] INITIAL_COUNTER = - Hex.decode("f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff"); + private static final byte[] INITIAL_COUNTER = Hex.decode("f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff"); @Test void encryptsNistMultiBlockStream() { - byte[] plain = Hex.decode( - "6bc1bee22e409f96e93d7e117393172a" - + "ae2d8a571e03ac9c9eb76fac45af8e51" - + "30c81c46a35ce411e5fbc1191a0a52ef" - + "f69f2445df4f9b17ad2b417be66c3710"); - byte[] expected = Hex.decode( - "874d6191b620e3261bef6864990db6ce" - + "9806f66b7970fdff8617187bb9fffdff" - + "5ae4df3edbd5d35e5b4f09020db03eab" - + "1e031dda2fbe03d1792170a0f3009cee"); + byte[] plain = + Hex.decode( + "6bc1bee22e409f96e93d7e117393172a" + + "ae2d8a571e03ac9c9eb76fac45af8e51" + + "30c81c46a35ce411e5fbc1191a0a52ef" + + "f69f2445df4f9b17ad2b417be66c3710"); + byte[] expected = + Hex.decode( + "874d6191b620e3261bef6864990db6ce" + + "9806f66b7970fdff8617187bb9fffdff" + + "5ae4df3edbd5d35e5b4f09020db03eab" + + "1e031dda2fbe03d1792170a0f3009cee"); assertArrayEquals(expected, AesCtr.crypt(KEY, INITIAL_COUNTER, plain)); } @@ -48,13 +47,14 @@ void encryptsPartialBlock() { @Test void rejectsWrongIvLength() { - assertThrows(IllegalArgumentException.class, - () -> AesCtr.crypt(KEY, new byte[15], new byte[0])); + assertThrows( + IllegalArgumentException.class, () -> AesCtr.crypt(KEY, new byte[15], new byte[0])); } @Test void rejectsWrongKeyLength() { - assertThrows(IllegalArgumentException.class, + assertThrows( + IllegalArgumentException.class, () -> AesCtr.crypt(new byte[24], INITIAL_COUNTER, new byte[0])); } } diff --git a/lib/src/test/java/org/openminimed/sake/crypto/AesEcbTest.java b/lib/src/test/java/org/openminimed/sake/crypto/AesEcbTest.java index 0e3f7e5..e8b42e4 100644 --- a/lib/src/test/java/org/openminimed/sake/crypto/AesEcbTest.java +++ b/lib/src/test/java/org/openminimed/sake/crypto/AesEcbTest.java @@ -1,14 +1,12 @@ package org.openminimed.sake.crypto; -import org.junit.jupiter.api.Test; -import org.openminimed.sake.Hex; - import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertThrows; -/** - * Known-answer tests from NIST SP 800-38A Appendix F.1 (AES-128 ECB). - */ +import org.junit.jupiter.api.Test; +import org.openminimed.sake.Hex; + +/** Known-answer tests from NIST SP 800-38A Appendix F.1 (AES-128 ECB). */ class AesEcbTest { private static final byte[] KEY = Hex.decode("2b7e151628aed2a6abf7158809cf4f3c"); @@ -37,13 +35,12 @@ void decryptsFirstNistBlock() { @Test void rejectsWrongKeyLength() { byte[] plain = new byte[16]; - assertThrows(IllegalArgumentException.class, - () -> AesEcb.encryptBlock(new byte[15], plain)); + assertThrows( + IllegalArgumentException.class, () -> AesEcb.encryptBlock(new byte[15], plain)); } @Test void rejectsWrongBlockLength() { - assertThrows(IllegalArgumentException.class, - () -> AesEcb.encryptBlock(KEY, new byte[15])); + assertThrows(IllegalArgumentException.class, () -> AesEcb.encryptBlock(KEY, new byte[15])); } } diff --git a/lib/src/test/java/org/openminimed/sake/package-info.java b/lib/src/test/java/org/openminimed/sake/package-info.java index df5a666..3862efc 100644 --- a/lib/src/test/java/org/openminimed/sake/package-info.java +++ b/lib/src/test/java/org/openminimed/sake/package-info.java @@ -1,4 +1,2 @@ -/** - * Unit tests for the SAKE handshake state machine. - */ +/** Unit tests for the SAKE handshake state machine. */ package org.openminimed.sake; From 00c08aef9ab7f2454fceaf3629468e82b828e4c2 Mon Sep 17 00:00:00 2001 From: jlengelbrecht Date: Thu, 14 May 2026 03:02:23 -0400 Subject: [PATCH 10/10] Address CodeRabbit findings: tighten bounds, docs and version pin Real defensive changes: - libs.versions.toml: pin junit-platform-launcher to 1.11.3 explicitly rather than rely on transitive alignment. The platform artifacts use the 1.11.x line, distinct from the jupiter 5.11.x line, so they need their own version key. - KeyDatabase: reject construction with more than 255 remote-device entries. The wire format encodes the count in one byte; oversize maps would silently truncate on serialization. New constant MAX_REMOTE_DEVICES. - SeqCrypt.encrypt: reject txSeq at or beyond 2^40. The IV layout stores the sequence in five big-endian bytes; encrypting past the boundary would silently truncate the sequence and risk IV reuse. New constant MAX_SEQ. - SeqCrypt.decrypt: reject incoming packets whose decoded delta exceeds 128 trailer-byte units. A forged but MAC-valid packet at a far-future sequence number could otherwise permanently desync rxSeq. New constant MAX_RX_DELTA. - Hex test helper: report the correct character index when only the low nibble is invalid (was reporting the high-nibble index for both cases). Documentation improvements covering thread-safety, AES-ECB usage boundary, AES-CTR IV uniqueness invariant, AES-CMAC digest/verify state reset semantics, the 16-bit MAC trade-off in the SAKE wire protocol, Constants as shared protocol values rather than session secrets, and the intentional live-reference semantics of Session.clientCrypt() and serverCrypt(). Three new test classes / methods covering the new bounds: HexTest (4 tests) SeqCryptTest.decryptRejectsDelta... (1 test) SeqCryptTest.encryptRejectsSeq... (1 test) KeyDatabaseTest.constructorRejects... (1 test) Total test count 65/65 green. Remaining findings (false positives, test-only helpers, intentional design choices) are documented in the commit history or javadoc rather than mechanically applied. --- gradle/libs.versions.toml | 3 +- .../java/org/openminimed/sake/Constants.java | 6 +++ .../org/openminimed/sake/KeyDatabase.java | 10 +++++ .../java/org/openminimed/sake/RngSource.java | 8 ++-- .../java/org/openminimed/sake/SeqCrypt.java | 36 ++++++++++++++-- .../java/org/openminimed/sake/Session.java | 11 +++++ .../org/openminimed/sake/crypto/AesCmac.java | 14 ++++++- .../org/openminimed/sake/crypto/AesCtr.java | 12 ++++-- .../openminimed/sake/crypto/package-info.java | 5 +++ .../test/java/org/openminimed/sake/Hex.java | 5 ++- .../java/org/openminimed/sake/HexTest.java | 42 +++++++++++++++++++ .../org/openminimed/sake/KeyDatabaseTest.java | 21 ++++++++++ .../org/openminimed/sake/SeqCryptTest.java | 23 ++++++++++ 13 files changed, 184 insertions(+), 12 deletions(-) create mode 100644 lib/src/test/java/org/openminimed/sake/HexTest.java diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 68400cb..75ed71f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,6 @@ [versions] junit = "5.11.3" +junitPlatform = "1.11.3" bouncycastle = "1.79" spotless = "6.25.0" googleJavaFormat = "1.22.0" @@ -7,7 +8,7 @@ googleJavaFormat = "1.22.0" [libraries] junit-jupiter-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit" } junit-jupiter-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junit" } -junit-platform-launcher = { group = "org.junit.platform", name = "junit-platform-launcher" } +junit-platform-launcher = { group = "org.junit.platform", name = "junit-platform-launcher", version.ref = "junitPlatform" } bouncycastle = { group = "org.bouncycastle", name = "bcprov-jdk18on", version.ref = "bouncycastle" } [plugins] diff --git a/lib/src/main/java/org/openminimed/sake/Constants.java b/lib/src/main/java/org/openminimed/sake/Constants.java index 0ac106d..da3e45c 100644 --- a/lib/src/main/java/org/openminimed/sake/Constants.java +++ b/lib/src/main/java/org/openminimed/sake/Constants.java @@ -8,6 +8,12 @@ * *

These are the three databases shipped with the reference Python implementation in {@code * pysake/constants.py}. The hex strings are reproduced verbatim and parsed at class-init time. + * + *

The byte sequences here are shared SAKE protocol constants extracted from pump + * firmware: a real 780-series pump contains the same values internally and uses them to + * authenticate any phone-side application it pairs with. They are not session secrets and they are + * not unique per device, so embedding them in source is correct (and necessary; the protocol is not + * negotiable without them). */ public final class Constants { diff --git a/lib/src/main/java/org/openminimed/sake/KeyDatabase.java b/lib/src/main/java/org/openminimed/sake/KeyDatabase.java index 40a8c9b..12479b0 100644 --- a/lib/src/main/java/org/openminimed/sake/KeyDatabase.java +++ b/lib/src/main/java/org/openminimed/sake/KeyDatabase.java @@ -33,6 +33,9 @@ public final class KeyDatabase { private final Map remoteDevices; private final byte[] crc; + /** Maximum number of remote-device entries: the wire format encodes the count in one byte. */ + public static final int MAX_REMOTE_DEVICES = 0xFF; + public KeyDatabase( DeviceType localDeviceType, Map remoteDevices, byte[] crc) { this.localDeviceType = Objects.requireNonNull(localDeviceType, "localDeviceType"); @@ -41,6 +44,13 @@ public KeyDatabase( if (crc.length != CRC_SIZE) { throw new IllegalArgumentException("crc must be " + CRC_SIZE + " bytes"); } + if (remoteDevices.size() > MAX_REMOTE_DEVICES) { + throw new IllegalArgumentException( + "remoteDevices size " + + remoteDevices.size() + + " exceeds wire-format limit " + + MAX_REMOTE_DEVICES); + } this.remoteDevices = Collections.unmodifiableMap(new LinkedHashMap<>(remoteDevices)); this.crc = crc.clone(); } diff --git a/lib/src/main/java/org/openminimed/sake/RngSource.java b/lib/src/main/java/org/openminimed/sake/RngSource.java index a120935..af44135 100644 --- a/lib/src/main/java/org/openminimed/sake/RngSource.java +++ b/lib/src/main/java/org/openminimed/sake/RngSource.java @@ -4,13 +4,15 @@ * Source of random bytes used to populate handshake fields the server / client are expected to * choose freshly per session. * - *

The default implementation is backed by {@link java.security.SecureRandom}. Tests can - * substitute a deterministic source to drive a server or client against a captured packet trace. + *

Production implementations should be backed by {@link java.security.SecureRandom}; see {@link + * SecureRandomRngSource}. Tests can substitute a deterministic source to drive a server or client + * against a captured packet trace. */ public interface RngSource { /** - * @return {@code n} fresh random bytes. + * @param n the number of bytes to return. Must be non-negative. + * @return a freshly allocated array of {@code n} random bytes. */ byte[] nextBytes(int n); } diff --git a/lib/src/main/java/org/openminimed/sake/SeqCrypt.java b/lib/src/main/java/org/openminimed/sake/SeqCrypt.java index 53cab41..e7039bb 100644 --- a/lib/src/main/java/org/openminimed/sake/SeqCrypt.java +++ b/lib/src/main/java/org/openminimed/sake/SeqCrypt.java @@ -13,11 +13,24 @@ * is three bytes: * *

- *   [ (seq >> 1) & 0xFF ][ CMAC4(nonce.padTo16 || ciphertext)[0..2] ]
+ *   [ (seq >> 1) & 0xFF ][ CMAC4(nonce.padTo16 || ciphertext)[0..1] ]
  * 
* - *

The receiver reconstructs the full 32-bit sequence from its local {@code rxSeq} and the 1-byte - * field in the trailer, tolerating an 8-bit wrap-around. + *

The receiver reconstructs the full sequence from its local {@code rxSeq} and the 1-byte field + * in the trailer, tolerating an 8-bit wrap-around. Deltas larger than {@link #MAX_RX_DELTA} are + * rejected to bound the damage from a forged but MAC-valid packet. + * + *

The two-byte trailer MAC is dictated by the SAKE wire protocol: it is a sixteen-bit + * authentication tag, not a full-strength MAC, and reflects the protocol's defence-in-depth + * trade-off between packet size and forgery resistance. + * + *

The IV layout stores the sequence as five big-endian bytes followed by the eight-byte nonce + * and three zero counter bytes. The five-byte sequence field bounds {@code txSeq} to less than + * {@link #MAX_SEQ}; encryption past that point would silently truncate the sequence and risk IV + * reuse, so {@link #encrypt(byte[])} rejects it. + * + *

Instances are not thread-safe. External synchronisation is required if a single instance is + * shared across threads. */ public final class SeqCrypt { @@ -27,6 +40,16 @@ public final class SeqCrypt { private static final int MAC_SIZE = 4; private static final int IV_SIZE = 16; + /** Exclusive upper bound on {@code txSeq}: 2^40, matching the 5-byte sequence prefix. */ + public static final long MAX_SEQ = 1L << 40; + + /** + * Maximum accepted delta (in trailer-byte units) between an incoming packet's sequence and the + * receiver's tracked {@code rxSeq}. A delta of {@value} corresponds to skipping 256 packets + * forward; larger jumps are treated as forgery attempts. + */ + public static final int MAX_RX_DELTA = 128; + private final byte[] key; private final byte[] nonce; private long txSeq; @@ -50,6 +73,9 @@ public SeqCrypt(byte[] key, byte[] nonce, long initialSeq) { public byte[] encrypt(byte[] plaintext) { Objects.requireNonNull(plaintext, "plaintext"); long seq = txSeq; + if (seq < 0 || seq >= MAX_SEQ) { + throw new IllegalStateException("txSeq " + seq + " exceeds the 2^40 IV sequence bound"); + } byte[] iv = buildIv(seq); byte[] ciphertext = AesCtr.crypt(key, iv, plaintext); byte[] tagPrefix = computeTagPrefix(seq, ciphertext); @@ -78,6 +104,10 @@ public byte[] decrypt(byte[] message) throws MacFailureException { int seqByte = message[message.length - TRAILER_SIZE] & 0xFF; int delta = (seqByte - (int) ((rxSeq >>> 1) & 0xFF)) & 0xFF; + if (delta > MAX_RX_DELTA) { + throw new MacFailureException( + "Sequence delta " + delta + " exceeds reorder window of " + MAX_RX_DELTA); + } long seq = rxSeq + 2L * delta; int ciphertextLen = message.length - TRAILER_SIZE; diff --git a/lib/src/main/java/org/openminimed/sake/Session.java b/lib/src/main/java/org/openminimed/sake/Session.java index b7ec8e5..9c25f7a 100644 --- a/lib/src/main/java/org/openminimed/sake/Session.java +++ b/lib/src/main/java/org/openminimed/sake/Session.java @@ -248,10 +248,21 @@ public StaticKeys serverStaticKeys() { return serverStaticKeys; } + /** + * Returns the live {@link SeqCrypt} used for messages originating from the client. The + * reference is intentional: {@link SakeServer} and {@link SakeClient} drive the sequence + * counter forward by calling {@link SeqCrypt#encrypt(byte[])} / {@link + * SeqCrypt#decrypt(byte[])} directly on this instance. Callers outside the handshake should + * treat the returned object as read-only. + */ public SeqCrypt clientCrypt() { return clientCrypt; } + /** + * Returns the live {@link SeqCrypt} used for messages originating from the server. See {@link + * #clientCrypt()} for the rationale on returning the reference directly. + */ public SeqCrypt serverCrypt() { return serverCrypt; } diff --git a/lib/src/main/java/org/openminimed/sake/crypto/AesCmac.java b/lib/src/main/java/org/openminimed/sake/crypto/AesCmac.java index 6f6dde0..305cc18 100644 --- a/lib/src/main/java/org/openminimed/sake/crypto/AesCmac.java +++ b/lib/src/main/java/org/openminimed/sake/crypto/AesCmac.java @@ -12,6 +12,12 @@ *

The handshake uses both {@code macLen=8} (handshake CMAC chain) and {@code macLen=4} (permit * auth, SeqCrypt trailer prefix). BouncyCastle always produces a 16-byte tag; we truncate to {@code * macLen} bytes to match the PyCryptodome {@code mac_len} parameter. + * + *

State accumulates across {@link #update(byte[])} calls. Calling {@link #digest()} or {@link + * #verify(byte[])} computes the tag and resets the underlying {@code CMac}, leaving the instance + * ready to receive {@code update} calls for a fresh message. + * + *

Instances are not thread-safe. */ public final class AesCmac { @@ -40,6 +46,11 @@ public AesCmac update(byte[] data) { return this; } + /** + * Compute the truncated MAC over all data accumulated since construction (or the previous + * {@code digest}/{@code verify} call). The underlying state is reset; further {@link + * #update(byte[])} calls begin a new message. + */ public byte[] digest() { byte[] full = new byte[mac.getMacSize()]; mac.doFinal(full, 0); @@ -50,7 +61,8 @@ public byte[] digest() { } /** - * Constant-time comparison against an expected tag. + * Constant-time comparison against an expected tag. Internally calls {@link #digest()} and + * therefore resets the underlying MAC state. * * @return true if the computed digest matches {@code expected}, byte-for-byte. */ diff --git a/lib/src/main/java/org/openminimed/sake/crypto/AesCtr.java b/lib/src/main/java/org/openminimed/sake/crypto/AesCtr.java index b0a8156..61fb697 100644 --- a/lib/src/main/java/org/openminimed/sake/crypto/AesCtr.java +++ b/lib/src/main/java/org/openminimed/sake/crypto/AesCtr.java @@ -9,9 +9,15 @@ /** * AES-128 CTR stream encryption. * - *

The 16-byte IV is treated as the initial 128-bit counter and incremented by one per block. - * Callers are responsible for assembling the IV such that the counter region does not wrap into the - * nonce region. + *

The 16-byte IV is passed unchanged to the JDK's {@code AES/CTR/NoPadding} cipher, which treats + * it as a 128-bit initial counter and increments the whole thing by one per block. + * + *

The (key, IV) pair must be unique per encryption. Reusing the same key with the same IV + * for two different plaintexts completely breaks CTR-mode confidentiality and authenticity. Callers + * are responsible for assembling the IV so that (a) it differs across every encryption performed + * under a given key, and (b) the per-block counter increments never wrap into the bits that carry + * the unique nonce or sequence number. See {@link org.openminimed.sake.SeqCrypt} for the SAKE + * session's IV construction. * *

CTR is symmetric so the same method is used to encrypt and decrypt. */ diff --git a/lib/src/main/java/org/openminimed/sake/crypto/package-info.java b/lib/src/main/java/org/openminimed/sake/crypto/package-info.java index 936176d..08036cd 100644 --- a/lib/src/main/java/org/openminimed/sake/crypto/package-info.java +++ b/lib/src/main/java/org/openminimed/sake/crypto/package-info.java @@ -3,5 +3,10 @@ * *

AES-ECB and AES-CTR are served by the JDK's JCE provider. AES-CMAC is implemented via * BouncyCastle as the JDK does not ship it. + * + *

AES-ECB is intentionally limited to single sixteen-byte operations on freshly random or + * uniquely-derived inputs (the permit-block decrypt step and the session-key derivation step). It + * is never used to encrypt multi-block or structured plaintext, where ECB's deterministic block + * mapping would leak structure. */ package org.openminimed.sake.crypto; diff --git a/lib/src/test/java/org/openminimed/sake/Hex.java b/lib/src/test/java/org/openminimed/sake/Hex.java index a1f3284..1604ba9 100644 --- a/lib/src/test/java/org/openminimed/sake/Hex.java +++ b/lib/src/test/java/org/openminimed/sake/Hex.java @@ -12,9 +12,12 @@ public static byte[] decode(String hex) { for (int i = 0; i < out.length; i++) { int high = Character.digit(hex.charAt(2 * i), 16); int low = Character.digit(hex.charAt(2 * i + 1), 16); - if (high < 0 || low < 0) { + if (high < 0) { throw new IllegalArgumentException("Invalid hex character at index " + (2 * i)); } + if (low < 0) { + throw new IllegalArgumentException("Invalid hex character at index " + (2 * i + 1)); + } out[i] = (byte) ((high << 4) | low); } return out; diff --git a/lib/src/test/java/org/openminimed/sake/HexTest.java b/lib/src/test/java/org/openminimed/sake/HexTest.java new file mode 100644 index 0000000..70486b4 --- /dev/null +++ b/lib/src/test/java/org/openminimed/sake/HexTest.java @@ -0,0 +1,42 @@ +package org.openminimed.sake; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** Tests for the test-only {@link Hex} helper, including its diagnostic error indices. */ +class HexTest { + + @Test + void roundTripIsLossless() { + byte[] data = {0x00, 0x12, (byte) 0xAB, (byte) 0xFF}; + assertArrayEquals(data, Hex.decode(Hex.encode(data))); + } + + @Test + void rejectsOddLength() { + assertThrows(IllegalArgumentException.class, () -> Hex.decode("abc")); + } + + @Test + void invalidHighNibbleErrorReportsHighIndex() { + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> Hex.decode("gf")); + assertTrue( + ex.getMessage().contains("index 0"), + "expected high-nibble error to name index 0, got: " + ex.getMessage()); + } + + @Test + void invalidLowNibbleErrorReportsLowIndex() { + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> Hex.decode("fg")); + assertEquals( + "Invalid hex character at index 1", + ex.getMessage(), + "low-nibble error must point at the offending second character"); + } +} diff --git a/lib/src/test/java/org/openminimed/sake/KeyDatabaseTest.java b/lib/src/test/java/org/openminimed/sake/KeyDatabaseTest.java index 407a2f7..ae0ec58 100644 --- a/lib/src/test/java/org/openminimed/sake/KeyDatabaseTest.java +++ b/lib/src/test/java/org/openminimed/sake/KeyDatabaseTest.java @@ -78,6 +78,27 @@ void rejectsTruncatedBuffer() { assertThrows(IllegalArgumentException.class, () -> KeyDatabase.fromBytes(truncated)); } + @Test + void constructorRejectsRemoteDeviceCountAboveWireLimit() { + // DeviceType only has eight enum values, so we cannot actually fill 256 entries. + // Use a size-only Map view to drive the constructor down the size-validation branch. + java.util.Map oversize = + new java.util.AbstractMap<>() { + @Override + public java.util.Set> entrySet() { + return java.util.Set.of(); + } + + @Override + public int size() { + return KeyDatabase.MAX_REMOTE_DEVICES + 1; + } + }; + assertThrows( + IllegalArgumentException.class, + () -> new KeyDatabase(DeviceType.MOBILE_APPLICATION, oversize, new byte[4])); + } + @Test void reverseProducesValidDatabase() { for (String hex : new String[] {HEX_G4_CGM, HEX_PUMP_EXTRACTED, HEX_PUMP_HARDCODED}) { diff --git a/lib/src/test/java/org/openminimed/sake/SeqCryptTest.java b/lib/src/test/java/org/openminimed/sake/SeqCryptTest.java index d416f85..d89bdbc 100644 --- a/lib/src/test/java/org/openminimed/sake/SeqCryptTest.java +++ b/lib/src/test/java/org/openminimed/sake/SeqCryptTest.java @@ -106,4 +106,27 @@ void decryptHandlesEightBitSequenceWrap() throws Exception { assertArrayEquals(PLAIN_1, recovered); assertEquals(514L, rx.getRxSeq()); } + + /** + * The receiver must reject incoming sequences that imply a forward jump beyond the configured + * reorder window. A forged-but-MAC-valid packet at a far-future sequence number could otherwise + * permanently desync rxSeq. + */ + @Test + void decryptRejectsDeltaBeyondReorderWindow() { + SeqCrypt tx = new SeqCrypt(KEY, NONCE, 2L * (SeqCrypt.MAX_RX_DELTA + 1)); + byte[] cipher = tx.encrypt(PLAIN_2); + SeqCrypt rx = new SeqCrypt(KEY, NONCE, 0L); + assertThrows(MacFailureException.class, () -> rx.decrypt(cipher)); + } + + /** + * Encrypting past the 2^40 sequence boundary would silently truncate the sequence in the five + * IV prefix bytes and risk IV reuse. {@link SeqCrypt#encrypt(byte[])} must reject it. + */ + @Test + void encryptRejectsSequenceAtFortyBitBoundary() { + SeqCrypt sc = new SeqCrypt(KEY, NONCE, SeqCrypt.MAX_SEQ); + assertThrows(IllegalStateException.class, () -> sc.encrypt(PLAIN_2)); + } }