Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 0 additions & 19 deletions .idea/runConfigurations/NtlmSessionTest.xml

This file was deleted.

14 changes: 13 additions & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

<!-- test dependencies -->
<junit.jupiter.version>5.10.2</junit.jupiter.version>
<mockito.version>5.11.0</mockito.version>
<mockito.version>5.17.0</mockito.version>

<!-- build plugin dependencies -->
<dependency-check.version>9.1.0</dependency-check.version>
Expand Down Expand Up @@ -67,6 +67,12 @@
</dependency>

<!-- tests -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>33.4.0-jre</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
Expand All @@ -91,6 +97,12 @@
<version>0.14.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
<version>1.80</version>
<scope>compile</scope>
</dependency>
Comment on lines +100 to +105
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

move above <!-- tests -->; add comment what this is used for (we aim to provide a zero-dependency lib)

</dependencies>

<build>
Expand Down
1 change: 1 addition & 0 deletions src/main/java/module-info.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
module org.cryptomator.jsmb {
requires org.slf4j;
requires static org.jetbrains.annotations;
requires org.bouncycastle.provider;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok for now, but we should try to add a cmac impl without requiring further dependencies


// provides java.security.Provider with org.cryptomator.jsmb.ntlmv2.LegacyCryptoProvider; // only required, if we want to find the provider by name

Expand Down
97 changes: 95 additions & 2 deletions src/main/java/org/cryptomator/jsmb/TcpConnection.java
Original file line number Diff line number Diff line change
@@ -1,19 +1,34 @@
package org.cryptomator.jsmb;

import org.cryptomator.jsmb.common.MalformedMessageException;
import org.cryptomator.jsmb.common.NTStatus;
import org.cryptomator.jsmb.common.SMBMessage;
import org.cryptomator.jsmb.smb1.SMB1MessageParser;
import org.cryptomator.jsmb.smb1.SMB1Negotiator;
import org.cryptomator.jsmb.smb1.SmbComNegotiateRequest;
import org.cryptomator.jsmb.smb2.*;
import org.cryptomator.jsmb.smb2.Connection;
import org.cryptomator.jsmb.smb2.LogoffRequest;
import org.cryptomator.jsmb.smb2.NegotiateRequest;
import org.cryptomator.jsmb.smb2.Negotiator;
import org.cryptomator.jsmb.smb2.Runtime;
import org.cryptomator.jsmb.smb2.SMB2Message;
import org.cryptomator.jsmb.smb2.SMB2MessageParser;
import org.cryptomator.jsmb.smb2.Session;
import org.cryptomator.jsmb.smb2.SessionSetupRequest;
import org.cryptomator.jsmb.smb2.SessionSetupResponse;
import org.cryptomator.jsmb.util.Layouts;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.EOFException;
import java.io.IOException;
import java.lang.foreign.MemorySegment;
import java.net.Socket;
import java.util.Arrays;
import java.util.Objects;

import static org.cryptomator.jsmb.smb2.negotiate.GlobalCapabilities.SMB2_GLOBAL_CAP_ENCRYPTION;

class TcpConnection implements Runnable {

Expand All @@ -23,12 +38,14 @@ class TcpConnection implements Runnable {
private final Socket socket;
private final Connection connection;
private final Negotiator negotiator;
private final Runtime runtime;

public TcpConnection(TcpServer server, Socket socket) {
this.server = server;
this.socket = socket;
this.connection = new Connection(server.global);
this.negotiator = new Negotiator(server, connection);
this.runtime = new Runtime(connection);
}

@Override
Expand Down Expand Up @@ -85,9 +102,10 @@ private void handleSmb2Packet(MemorySegment segment) throws MalformedMessageExce
var response = switch (msg) {
case NegotiateRequest request -> negotiator.negotiate(request);
case SessionSetupRequest request -> negotiator.sessionSetup(request);
case LogoffRequest request -> runtime.logoff(request);
default -> throw new MalformedMessageException("Command not implemented: " + msg.header().command());
};
writeResponse(response);
writeResponse(sign(msg, response));
nextCommand = msg.header().nextCommand();
} while (nextCommand != 0);
}
Expand All @@ -106,4 +124,79 @@ private void writeResponse(SMBMessage response) {
LOG.error("Exception while writing response", e);
}
}

private SMBMessage sign(SMB2Message request, SMB2Message response) {
var sessionId = response.header().sessionId();
var session = connection.sessionTable.get(sessionId);
assert (sessionId == 0) == (session == null);
if (shouldSign(request, response, session)) {
assert Objects.equals(connection.dialect, "3.1.1");
return response.sign(selectKey(response, session), connection);
}
return response;
}

/**
* @see <a href="https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-smb2/d594481c-f6d5-4de5-8842-9099063d41e7">Signing the Message</a>
*/
private boolean shouldSign(SMB2Message request, SMB2Message response, @Nullable Session session) {
var signed = !Arrays.equals(request.header().signature(), new byte[16]);
var sessionId = response.header().sessionId();
var treeId = response.header().treeId();

assert signed == request.header().hasFlag(SMB2Message.Flags.SIGNED);
assert (sessionId == 0) == (session == null);
if (signed && sessionId != 0 && treeId == 0 && session.signingRequired) {
return true;
}
if (signed && sessionId != 0 && treeId != 0 && session.signingRequired && (!connection.global.encryptData || ((connection.clientCapabilities & SMB2_GLOBAL_CAP_ENCRYPTION) == 0))) {
return true;
}
if (signed && !response.header().hasFlag(SMB2Message.Flags.ASYNC_COMMAND)) {
return true;
}
return false;
}

/**
* @see <a href="https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-smb2/d594481c-f6d5-4de5-8842-9099063d41e7">Signing the Message</a>
*/
private byte[] selectKey(SMB2Message response, Session session) {
if (connection.dialect.startsWith("3.")) {
if (response instanceof SessionSetupResponse && response.header().status() != NTStatus.STATUS_SUCCESS) {
return session.signingKey;
}
return channelSigningKey(session);
}
return session.sessionKey;
}

/**
* Provides the {@code Channel.SigningKey} for signing a response.
*
* @apiNote This method implements the following specification from
* <a href="https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-smb2/d594481c-f6d5-4de5-8842-9099063d41e7">Signing the Message:</a>
* <i>
* <p>[...] For all other responses being signed the server
* MUST provide <b>Channel.SigningKey</b> by looking up the <b>Channel</b> in <b>Session.ChannelList</b>,
* where the connection matches the <b>Channel.Connection</b>.</p>
* </i>
* @implNote The current implementation of this method depends on two simplifications:
* <ul>
* <li>
* {@code Negotiator.gssAuthenticate()} doesn't accept {@link SessionSetupRequest SessionSetupRequests} with
* {@link SessionSetupRequest#FLAG_BINDING} set.</br>
* Therefore the value of {@code Channel.SigningKey} is always equal to {@link Session#signingKey}</br>
* See: <a href="https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-smb2/5ed93f06-a1d2-4837-8954-fa8b833c2654">Handling GSS-API Authentication (step 9)</a>
* </li>
* <li>
* {@code Channel} is not implemented and therefore the value of {@code Channel.SigningKey}
* is the same for all packets of this session.
* </li>
* </ul>
* As a result this method will always return {@link Session#signingKey}.
*/
private byte[] channelSigningKey(Session session) {
return session.signingKey;
}
}
1 change: 1 addition & 0 deletions src/main/java/org/cryptomator/jsmb/common/NTStatus.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ public interface NTStatus {
// https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-erref/596a1078-e883-4972-9bbc-49e60bebca55
int STATUS_SUCCESS = 0x00000000;
int STATUS_SMB_NO_PREAUTH_INTEGRITY_HASH_OVERLAP = 0xC05D0000;
int STATUS_REQUEST_NOT_ACCEPTED = 0xC00000D0;

// https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-smb/6ab6ca20-b404-41fd-b91a-2ed39e3762ea
int STATUS_MORE_PROCESSING_REQUIRED = 0xC0000016;
Expand Down
20 changes: 15 additions & 5 deletions src/main/java/org/cryptomator/jsmb/ntlmv2/Authenticator.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,35 @@
import java.nio.charset.StandardCharsets;
import java.util.Arrays;

import static org.cryptomator.jsmb.ntlmv2.NegotiateFlags.isSet;

/**
* Performs the NTLM v2 Authentication
* @see <a href="https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-nlmp/f9e6fbc4-a953-4f24-b229-ccdcc213b9ec">Server Receives an AUTHENTICATE_MESSAGE from the Client</a>
* @see <a href="https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-nlmp/5e550938-91d4-459f-b67d-75d70009e3f3">NTLM v2 Authentication</a>
*/
class Authenticator {

public static AuthResponse ntlmV2Auth(NtlmChallengeMessage challengeMessage, NtlmAuthenticateMessage authenticateMessage, String user, String passwd, String userDom) throws AuthenticationFailedException {
byte[] responseKeyNT = NTOWFv2(passwd, user, userDom);
byte[] responseKeyLM = LMOWFv2(passwd, user, userDom);
var serverChallenge = challengeMessage.serverChallenge();

if (authenticateMessage.userNameLen() == 0
&& authenticateMessage.ntChallengeResponseLen() == 0
&& (authenticateMessage.lmChallengeResponseLen() == 0 || Arrays.equals(new byte[]{0x00}, authenticateMessage.lmChallengeResponse()))) {
throw new AuthenticationFailedException(NTStatus.STATUS_LOGON_FAILURE, "Anonymouse authentication disabled");
}

byte[] responseKeyNT = NTOWFv2(passwd, user, userDom);
byte[] responseKeyLM = LMOWFv2(passwd, user, userDom);

var ntlmV2Response = authenticateMessage.ntlmV2Response();
byte[] challengeFromClient = ntlmV2Response.challengeFromClient();
byte[] challengeFromClient;
if (authenticateMessage.ntChallengeResponseLen() > 0x0018) {
challengeFromClient = ntlmV2Response.challengeFromClient();
} else if (isSet(challengeMessage.negotiateFlags(), NegotiateFlags.NTLMSSP_NEGOTIATE_EXTENDED_SESSIONSECURITY)) {
throw new UnsupportedOperationException("Not yet implemented");
} else {
throw new UnsupportedOperationException("Not yet implemented");
}
Comment on lines +35 to +37
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

else branch should be trivial:

     Else
       Set ChallengeFromClient to NIL
     EndIf

var serverChallenge = challengeMessage.serverChallenge();
var time = ntlmV2Response.timestamp();
var expectedResponse = computeResponse(responseKeyNT, responseKeyLM, serverChallenge, challengeFromClient, time, ntlmV2Response.avPairsSegment().toArray(Layouts.BYTE));

Expand Down
9 changes: 9 additions & 0 deletions src/main/java/org/cryptomator/jsmb/ntlmv2/Crypto.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ public static byte[] md4(byte[] input) {
}
}

public static byte[] md5(byte[] input) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
return md.digest(input);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("MD5 not found", e);
}
}

public static byte[] hmacMd5(byte[] key, byte[] data) {
try {
Mac mac = Mac.getInstance(HMAC_MD5_ALGORITHM);
Expand Down
26 changes: 20 additions & 6 deletions src/main/java/org/cryptomator/jsmb/ntlmv2/NtlmSession.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import org.cryptomator.jsmb.util.Bytes;

import java.lang.foreign.MemorySegment;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
Expand Down Expand Up @@ -118,16 +119,29 @@ public Authenticated authenticate(byte[] gssToken, String user, String password,
}
}


var clientSigningKey = signKey(negFlg, exportedSessionKey, "Client");
var serverSigningKey = signKey(negFlg, exportedSessionKey, "Server");
// TODO: derive session keys and return ntlm session object
// Set ClientSigningKey to SIGNKEY(NegFlg, ExportedSessionKey , "Client")
// Set ServerSigningKey to SIGNKEY(NegFlg, ExportedSessionKey , "Server")
// Set ClientSealingKey to SEALKEY(NegFlg, ExportedSessionKey , "Client")
// Set ServerSealingKey to SEALKEY(NegFlg, ExportedSessionKey , "Server")
return new Authenticated();
// Set ClientSealingKey to SEALKEY(NegFlg, ExportedSessionKey , "Client")
// Set ServerSealingKey to SEALKEY(NegFlg, ExportedSessionKey , "Server")
return new Authenticated(exportedSessionKey, clientSigningKey, serverSigningKey);
}

// https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-nlmp/524cdccb-563e-4793-92b0-7bc321fce096
private byte[] signKey(int flags, byte[] exportedSessionKey, String mode) {
if ((flags & NegotiateFlags.NTLMSSP_NEGOTIATE_EXTENDED_SESSIONSECURITY) != 0) {
byte[] magicConstant = "Client".equals(mode)
? "session key to client-to-server signing key magic constant\00".getBytes(StandardCharsets.US_ASCII)
: "session key to server-to-client signing key magic constant\00".getBytes(StandardCharsets.US_ASCII);
return Crypto.md5(Bytes.concat(exportedSessionKey, magicConstant));
} else {
return new byte[0];
}
}

}

final class Authenticated implements NtlmSession {
record Authenticated(byte[] exportedSessionKey, byte[] clientSigningKey, byte[] serverSigningKey) implements NtlmSession {
}
}
14 changes: 10 additions & 4 deletions src/main/java/org/cryptomator/jsmb/smb1/SMB1Negotiator.java
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
package org.cryptomator.jsmb.smb1;

import org.cryptomator.jsmb.TcpServer;
import org.cryptomator.jsmb.common.SMBMessage;
import org.cryptomator.jsmb.common.NTStatus;
import org.cryptomator.jsmb.smb2.*;
import org.cryptomator.jsmb.common.SMBMessage;
import org.cryptomator.jsmb.smb2.Command;
import org.cryptomator.jsmb.smb2.Connection;
import org.cryptomator.jsmb.smb2.Dialects;
import org.cryptomator.jsmb.smb2.NegotiateResponse;
import org.cryptomator.jsmb.smb2.PacketHeader;
import org.cryptomator.jsmb.smb2.SMB2Message;
import org.cryptomator.jsmb.smb2.negotiate.GlobalCapabilities;
import org.cryptomator.jsmb.smb2.negotiate.SecurityMode;
import org.cryptomator.jsmb.util.WinFileTime;
Expand All @@ -15,7 +20,8 @@
*
* @param server The server on behalf of which this negotiator acts
* @see <a href="https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-smb2/b99264a6-7520-4563-adaf-fc4fdf7d5a1b">Negotiation protocol example</a>
* @see <a href="https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-smb2/bcd6d594-017b-47fc-8742-b7d847791783">Receiving an SMB_COM_NEGOTIATE</a>
* @see <a href="https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-smb2/26646611-6a0f-4549-9c82-f9343e750a81">Receiving an SMB_COM_NEGOTIATE</a>
* @see <a href="https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-smb2/bcd6d594-017b-47fc-8742-b7d847791783">SMB 2.1 or SMB 3.x Support</a>
*/
public record SMB1Negotiator(TcpServer server, Connection connection) {

Expand All @@ -39,7 +45,7 @@ public SMBMessage negotiate(SmbComNegotiateRequest request) {
header.treeId(0);
header.sessionId(0L);
var response = new NegotiateResponse(header.build());
response.securityMode(SecurityMode.SIGNING_ENABLED);
response.securityMode((char) (SecurityMode.SIGNING_ENABLED | (connection.global.requireMessageSigning ? SecurityMode.SIGNING_REQUIRED : 0)));
response.dialectRevision(Dialects.SMB2_WILDCARD);
response.serverGuid(server.guid);
response.capabilities(GlobalCapabilities.SMB2_GLOBAL_CAP_LARGE_MTU);
Expand Down
3 changes: 2 additions & 1 deletion src/main/java/org/cryptomator/jsmb/smb2/Connection.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.cryptomator.jsmb.smb2;

import org.cryptomator.jsmb.smb2.negotiate.PreauthIntegrityCapabilities;
import org.cryptomator.jsmb.smb2.negotiate.SigningCapabilities;

import java.util.HashMap;
import java.util.Map;
Expand Down Expand Up @@ -32,7 +33,7 @@ public Connection(Global global) {
public char preauthIntegrityHashId = PreauthIntegrityCapabilities.HASH_ALGORITHM_SHA512;
public byte[] preauthIntegrityHashValue = new byte[64];
public char cipherId;
public char signingAlgorithmId;
public SigningCapabilities.Algorithm signingAlgorithmId;
public char[] compressionIds;
public boolean supportsChainedCompression;
Comment on lines 34 to 38
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Restore the default signing algorithm

Changing signingAlgorithmId from char to Algorithm removes the implicit default of 0x0000 (HMAC-SHA256). In SMB 2.x dialects the server never sends SIGNING_CAPABILITIES, so this field now stays null and any later getValue()/switch will blow up. Initialize it to Algorithm.HMAC_SHA256 to preserve the previous behavior.

-	public SigningCapabilities.Algorithm signingAlgorithmId;
+	public SigningCapabilities.Algorithm signingAlgorithmId = SigningCapabilities.Algorithm.HMAC_SHA256;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public byte[] preauthIntegrityHashValue = new byte[64];
public char cipherId;
public char signingAlgorithmId;
public SigningCapabilities.Algorithm signingAlgorithmId;
public char[] compressionIds;
public boolean supportsChainedCompression;
public byte[] preauthIntegrityHashValue = new byte[64];
public char cipherId;
public SigningCapabilities.Algorithm signingAlgorithmId = SigningCapabilities.Algorithm.HMAC_SHA256;
public char[] compressionIds;
public boolean supportsChainedCompression;
🤖 Prompt for AI Agents
In src/main/java/org/cryptomator/jsmb/smb2/Connection.java around lines 34 to
38, the signingAlgorithmId field was changed from a primitive char to
SigningCapabilities.Algorithm and thus can be null when the server omits
SIGNING_CAPABILITIES; restore the implicit default by initializing
signingAlgorithmId to SigningCapabilities.Algorithm.HMAC_SHA256 in its
declaration so it defaults to HMAC-SHA256 (0x0000) and avoids NPEs in later
getValue()/switch usage.

public char[] RDMATransformIds;
Expand Down
5 changes: 5 additions & 0 deletions src/main/java/org/cryptomator/jsmb/smb2/Global.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,9 @@ public class Global {
Map<Long, Session> sessionTable = new HashMap<>();
Map<Long, Object> clientTable = new HashMap<>(); // TODO: create Client class

public final boolean encryptData = true;
public final boolean rejectUnencryptedAccess = true;
public final boolean requireMessageSigning = true;

public final boolean isMultiChannelCapable = false;
}
19 changes: 19 additions & 0 deletions src/main/java/org/cryptomator/jsmb/smb2/LogoffRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package org.cryptomator.jsmb.smb2;

import org.cryptomator.jsmb.util.Layouts;

import java.lang.foreign.MemorySegment;

/**
* A SMB2 LOGOFF Request
*
* @see <a href="https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-smb2/abdc4ea9-52df-480e-9a36-34f104797d2c">SMB2 LOGOFF Request Specification</a>
*/
public record LogoffRequest(PacketHeader header, MemorySegment segment) implements SMB2Message {

public char structureSize() {
return segment.get(Layouts.LE_UINT16, 0); //Should always be 4
}

//Reserved: 2 bytes @ offset 2
}
Loading