Skip to content

SMB Protocol Internals

This document is aimed at contributors to the DittoFS SMB implementation. It covers wire formats, message flows, key derivation, lease and durable-handle internals, cross-protocol coordination, credit flow control, and code structure.

Operators and end-users: see ../guide/smb.md for mount instructions, configuration, and client-facing behaviour.


AspectNFS (v3/v4)SMB2 (2.0.2)SMB3 (3.0-3.1.1)
OriginUnix (Sun Microsystems, 1984)Windows (IBM/Microsoft, 1983)Windows (Microsoft, 2012)
Designv3: Stateless / v4: StatefulStateful, session-basedStateful, session-based
IdentityUID/GID (Unix)SID (Windows Security ID)SID + Kerberos principal
PermissionsUnix mode bits / NFSv4 ACLsACLs (Access Control Lists)ACLs
TransportTCP (port 2049)TCP (port 445)TCP (port 445)
FramingRPC record markingNetBIOS session headerNetBIOS + Transform header
EncodingXDR (big-endian)Custom (little-endian)Custom (little-endian)
HeaderVariable (RPC)Fixed 64 bytesFixed 64 bytes (+ 52-byte transform)
StringsUTF-8UTF-16LEUTF-16LE
Flow controlNone (relies on TCP)Credit-basedCredit-based
Encryptionkrb5p (RPCSEC_GSS)NoneAES-GCM / AES-CCM (transform header)
Signingkrb5i (RPCSEC_GSS)HMAC-SHA256AES-CMAC / AES-GMAC
Client cachingDelegationsOplocksLeases V2 (file + directory)
Handle resilienceVolatileVolatileDurable / Persistent handles

NFS-to-SMB concept mapping:

NFS ConceptSMB EquivalentNotes
ExportShareNetwork-accessible directory
MountTree ConnectEstablishing access to a share
File HandleFileIDOpaque identifier for open file
UID/GIDSIDUser/group identity
Mode bitsSecurity DescriptorPermission model
LOOKUPPart of CREATESMB combines lookup and open
GETATTRQUERY_INFOGet file metadata
SETATTRSET_INFOSet file metadata
READDIRQUERY_DIRECTORYList directory contents
COMMITFLUSHSync to disk
DelegationLease V2Client caching grant
CB_RECALLLease Break NotificationCache invalidation
CB_NOTIFYCHANGE_NOTIFYDirectory change events

Every SMB2 message follows this structure:

+------------------------------------------------------------+
| NetBIOS Session Header |
| (4 bytes) |
+------------------------------------------------------------+
| SMB2 Header |
| (64 bytes) |
+------------------------------------------------------------+
| Command Body |
| (variable) |
+------------------------------------------------------------+

For SMB3 encrypted messages, a Transform Header wraps the entire message:

+------------------------------------------------------------+
| NetBIOS Session Header |
| (4 bytes) |
+------------------------------------------------------------+
| Transform Header (0xFD534D42) |
| (52 bytes) |
| Signature (16) | Nonce (16) | OrigMsgSize (4) |
| Reserved (2) | Flags (2) | SessionID (8) |
+------------------------------------------------------------+
| Encrypted Payload |
| (SMB2 Header + Command Body, encrypted) |
+------------------------------------------------------------+

The NetBIOS session header contains a type byte (0x00 for session messages) and a 24-bit big-endian length. The SMB2 header is always 64 bytes and includes the protocol magic (0xFE 'S' 'M' 'B'), command code, credit charge/grant, session ID, tree ID, message ID, flags, and signature. The Transform header uses magic 0xFD 'S' 'M' 'B' and carries the AEAD nonce and authentication tag.


SMB connections follow a multi-phase setup before file operations can begin:

  1. NEGOTIATE — Client and server agree on protocol dialect, capabilities, and security parameters (cipher suites, signing algorithms, preauth integrity)
  2. SESSION_SETUP — Client authenticates (NTLM or Kerberos via SPNEGO), receives a SessionID; session keys are derived and encryption/signing activated
  3. TREE_CONNECT — Client connects to a specific share, receives a TreeID; per-share encryption may be enforced
  4. File Operations — CREATE opens a file (returns FileID), then READ/WRITE/CLOSE use that FileID
  5. Cleanup — CLOSE releases file handles, TREE_DISCONNECT leaves the share, LOGOFF ends the session

This is fundamentally different from NFS, where each request is independent and carries its own auth context.


  1. TCP connection accepted
  2. NetBIOS session header parsed
  3. SMB2 message decoded (decrypted if transform header present)
  4. Session/tree context validated
  5. Command handler dispatched
  6. Handler calls metadata/block stores
  7. Response encoded (encrypted if session requires it) and sent
// Per-connection parallel request handling
for {
msg := readSMB2Message(conn)
go handleRequest(msg) // Concurrent handling
}

Session Management (internal/adapter/smb/handlers/)

  • NEGOTIATE: Multi-dialect negotiation with negotiate contexts (cipher, signing, preauth)
  • SESSION_SETUP: NTLM or Kerberos authentication via SPNEGO, key derivation
  • TREE_CONNECT: Share access with permission validation, per-share encryption enforcement

File Operations (internal/adapter/smb/handlers/)

  • CREATE: Create/open files and directories, lease V2 grants, durable handle create contexts
  • READ: Read file content (with cache support)
  • WRITE: Write file content (with cache support)
  • CLOSE: Close file handle and cleanup
  • FLUSH: Flush cached data to block store
  • QUERY_INFO: Get file/directory attributes
  • SET_INFO: Modify attributes, rename, delete
  • QUERY_DIRECTORY: List directory contents
  • LOCK: Acquire/release byte-range locks
  • IOCTL: VALIDATE_NEGOTIATE_INFO, server-side copy

NFS Implementation: SMB Implementation:
internal/adapter/nfs/ internal/adapter/smb/
+-- dispatch.go +-- dispatch.go
+-- rpc/ +-- header/
| +-- message.go | +-- header.go
| +-- reply.go | +-- parser.go
+-- xdr/ | +-- encoder.go
| +-- reader.go +-- auth/
| +-- writer.go | +-- ntlm/
+-- types/ | +-- spnego/
| +-- constants.go +-- smbenc/
+-- mount/handlers/ | +-- encrypt.go
| +-- mnt.go | +-- decrypt.go
| +-- export.go +-- signing/
+-- v3/handlers/ | +-- hmac.go
| +-- lookup.go | +-- cmac.go
| +-- read.go | +-- gmac.go
| +-- write.go +-- kdf/
+-- v4/handlers/ | +-- sp800_108.go
| +-- compound.go +-- lease/
| +-- delegation.go | +-- manager.go
| +-- state/ | +-- notifier.go
+-- types/
| +-- constants.go
| +-- status.go
| +-- filetime.go
+-- v2/handlers/
+-- handler.go
+-- negotiate.go
+-- session_setup.go
+-- tree_connect.go
+-- create.go
+-- read.go
+-- write.go
+-- ioctl.go
+-- durable.go
...

WRITE operations use a two-phase commit pattern:

// 1. Prepare write (validate permissions, get ContentID)
writeOp, err := metadataStore.PrepareWrite(authCtx, handle, newSize)
// 2. Resolve per-share block store and write data
blockStore, _ := rt.GetBlockStoreForHandle(ctx, handle)
blockStore.WriteAt(ctx, writeOp.ContentID, data, offset)
// 3. Commit write (update metadata: size, timestamps)
metadataStore.CommitWrite(authCtx, writeOp)

SMB handlers use the same per-share block store as NFS, routed through the shared internal/adapter/common/ helpers so NFS and SMB share one code path for block-store resolution and pooled READ:

// Resolve per-share block store from file handle
blockStore, err := common.ResolveForRead(ctx.Context, h.Registry, handle)
// Read path (pooled buffer; release fires after wire write completes)
readResult, err := common.ReadFromBlockStore(ctx.Context, blockStore,
payloadID, offset, count)
// Response hands readResult.Release to the encoder via SMBResponseBase.ReleaseData
// Write path (data is caller-owned, no Release closure)
err := common.WriteToBlockStore(ctx.Context, blockStore, payloadID, data, offset)
// Commit path (flush + discard *FlushResult)
err := common.CommitBlockStore(ctx.Context, blockStore, payloadID)

These three helpers (ReadFromBlockStore, WriteToBlockStore, CommitBlockStore) are the seam that plumbs []BlockRef into the engine — handler code does not change.

SMB2 READ responses for regular files allocate the data buffer through internal/adapter/pool (4 KB / 64 KB / 1 MB tiered sync.Pool, with a direct-alloc fallback for sizes above LargeSize). The pooled buffer is handed to the response encoder via SMBResponseBase.ReleaseData (a func() field); the encoder invokes the closure after WriteNetBIOSFrame returns, safe across plain, encrypted, and compound-response paths. Non-pooled responses leave ReleaseData nil and the encoder null-checks before invoking.

Pipe and symlink READ variants deliberately stay on heap allocations — memcpy overhead with no reuse benefit for the small buffer sizes involved, and pipes have an ownership model that conflicts with a pool-managed return buffer. Regression tests (TestRead_PipeRead_LeavesReleaseDataNil / TestRead_SymlinkRead_...) guard the non-pool decision.


Every metadata.ErrorCode is translated to an NTSTATUS via internal/adapter/common.MapToSMB, which consumes the same shared table as NFSv3 / NFSv4 (internal/adapter/common/errmap.go). Examples:

  • ErrNotFoundSTATUS_OBJECT_NAME_NOT_FOUND
  • ErrAlreadyExistsSTATUS_OBJECT_NAME_COLLISION
  • ErrAccessDenied / ErrPermissionDenied / ErrAuthRequiredSTATUS_ACCESS_DENIED (SMB has no EPERM distinction per MS-ERREF 2.3)
  • ErrIsDirectorySTATUS_FILE_IS_A_DIRECTORY
  • ErrStaleHandleSTATUS_FILE_CLOSED

Lock-Context vs General-Context Divergence

Section titled “Lock-Context vs General-Context Divergence”

Lock-operation errors (SMB2 LOCK requests) use a separate accessor common.MapLockToSMB backed by internal/adapter/common/lock_errmap.go. The divergence matters: ErrLocked in lock context → STATUS_LOCK_NOT_GRANTED; ErrLocked in general READ/WRITE I/O context → STATUS_FILE_LOCK_CONFLICT. Clients react differently to the two codes (retry-later vs. hard-fail-with-indication), so the distinction is wire-visible.

See internal/adapter/common/lock_errmap.go for the full lock-context override table.

common.MapToSMB uses errors.As, so wrapped StoreError values (fmt.Errorf("context: %w", storeErr)) unwrap correctly. Prior to v0.15.0 the SMB handler used an unwrapped type assertion that failed on wrapped errors and fell through to STATUS_INTERNAL_ERROR — the consolidation fixed that latent bug.


SMB2 uses credits (MS-SMB2 3.3.1.2) as the protocol-level flow-control mechanism. Each request consumes credits equal to its CreditCharge; each response grants credits via the CreditResponse header field. The client tracks a per-connection running balance (cur_credits) and will refuse to send a request once its balance would go negative, or reject a response whose grant would overflow its 16-bit counter. Both outcomes look like NT_STATUS_INTERNAL_ERROR or NT_STATUS_INVALID_NETWORK_RESPONSE on the wire, so credit accounting must be byte-for-byte consistent between the server’s window and the client’s counter.

type CreditConfig struct {
MinGrant uint16 // Minimum credits per response (1)
MaxGrant uint16 // Maximum credits per response (8192)
InitialGrant uint16 // Floor when client requests 0 (1)
MaxSessionCredits uint32 // Per-connection window cap (8192)
}

The defaults match Samba’s server (smb2 max credits = 8192, initial grant = 1 in source3/smbd/smb2_server.c) and Windows Server 2008R2+. These are the protocol-level invariants clients expect; tuning them higher can break interoperability.

Server Data Structure: CommandSequenceWindow

Section titled “Server Data Structure: CommandSequenceWindow”

One per connection. Tracks granted message IDs as a sliding bitmap (internal/adapter/smb/session/sequence_window.go):

low high
│ span=high-low │
▼ ▼
[0111100011001110000...] bit i = sequence (low+i) is granted-and-unconsumed
set by Grant, cleared by Consume
available = the server's view of the client's cur_credits
(initially equal to popcount(bitmap); decoupled by Reclaim)

Three invariants drive correctness:

  1. available mirrors the client’s cur_credits. Every Grant(N) increments available by the amount the server actually extended the window; every Consume(msgId, charge) decrements available by charge. The server never grants more than MaxSessionCredits - available, so the client’s counter can never overflow.
  2. low advances lazily in 64-bit blocks. advanceLow reclaims bitmap words once an entire 64-sequence run has been consumed. The available counter is the authoritative credit tally; the bitmap span (high - low) can briefly exceed available when the oldest unconsumed bit is still in place, but stays bounded because available gates new grants.
  3. Credit-exempt commands still consume sequence numbers. MS-SMB2 exempts NEGOTIATE, CANCEL, and the first SESSION_SETUP (SessionID=0) from credit validation, but the client still advances its msgId and decrements cur_credits for them. The server therefore MUST call Consume on those messages too — otherwise available drifts up by one per credit-exempt request, saturates at MaxSessionCredits, and future responses carry credits=0 until the client runs out of credits (observed in issue #378).

MS-SMB2 3.2.4.1.4 requires middle responses in a compound to advertise Credits=0. Our response builder grants credits atomically before the write (see below), so after zeroing the middle headers the window would be over-extended relative to what the client was told. Reclaim(n) decrements available by n without touching the bitmap — the reclaimed message IDs remain valid on the server (a misbehaving client that sent one would still pass Consume), but the client was never told about them and will not use them under normal operation. Consume saturates available at zero rather than underflowing if a reclaimed message ID is used anyway.

GrantCredits (per-session policy) → credits (requested grant)
└─ strategy-dependent (echo/fixed/adaptive)
CommandSequenceWindow.Grant(credits) → credits' (may be less; ≤ MaxSessionCredits - available)
└─ extends the window and updates `available` atomically under w.mu
respHeader.Credits = credits'
...send response...

The grant is recorded against the window before the response is written, and the grant function returns the actual amount extended, so the value advertised in hdr.Credits is always exactly what the window was extended by. This closes the TOCTOU gap that a “read Remaining(), clamp, write, then Grant()” pattern would leave open when pipelined responses run on the same connection. All response build sites funnel through grantConnectionCredits in internal/adapter/smb/response.go.

  • Echo (default): grant what the client requests, bounded by [MinGrant, MaxGrant] and Remaining(). Matches Samba’s smb2_set_operation_credit: grant = credit_charge + (requested - 1).
  • Fixed: always grant InitialGrant.
  • Adaptive: InitialGrant scaled by live load and client-outstanding factors. More aggressive than Echo, primarily useful when throughput matters more than strict Samba interop.
  • Samba client hard-caps cur_credits at uint16 max (65535) and rejects any response that would overflow. Prior to #378 we advertised ~384 credits per response (InitialGrant=256 x adaptive 1.5x boost), which saturated the client after ~85 SESSION_SETUP iterations and triggered NT_STATUS_INVALID_NETWORK_RESPONSE. The fix lowered defaults to Samba-compatible values and enforced Remaining() clamping at every response build site.
  • Windows client is more tolerant but grants are capped by the negotiated Connection.MaxCredits; setting MaxSessionCredits > 8192 gains nothing because Windows caps at 8192 by default too.
  • Multi-credit operations (large READ/WRITE) consume CreditCharge sequence numbers per request; the window handles charge > 1 natively.

References:

  • MS-SMB2 3.3.1.2 (Server Credit Tracking)
  • Samba source3/smbd/smb2_server.c smb2_set_operation_credit and surrounding bitmap bookkeeping
  • Samba client check: libcli/smb/smbXcli_base.c:4295-4298

The NEGOTIATE request contains a list of dialect revisions supported by the client. The server selects the highest dialect both sides support:

PriorityDialectHexKey Capability
1 (highest)SMB 3.1.10x0311Preauth integrity, negotiate contexts
2SMB 3.0.20x0302VALIDATE_NEGOTIATE_INFO
3SMB 3.00x0300Encryption (AES-CCM), CMAC signing
4 (lowest)SMB 2.0.20x0202Basic SMB2 operations

When the negotiated dialect is 3.1.1, both client and server exchange negotiate contexts that specify security parameters:

SMB2_PREAUTH_INTEGRITY_CAPABILITIES:

  • Hash algorithm: SHA-512 (mandatory)
  • Salt: random 32-byte value per side
  • Purpose: preauth integrity hash chain for downgrade protection

SMB2_ENCRYPTION_CAPABILITIES:

  • Supported ciphers in preference order
  • Server selects the first mutually supported cipher

SMB2_SIGNING_CAPABILITIES:

  • Supported signing algorithms in preference order
  • Server selects the first mutually supported algorithm

For SMB 3.1.1, a running SHA-512 hash is computed over the raw NEGOTIATE and SESSION_SETUP request/response bytes:

PreauthHash[0] = SHA-512(Salt || NEGOTIATE_REQUEST_bytes)
PreauthHash[1] = SHA-512(PreauthHash[0] || NEGOTIATE_RESPONSE_bytes)
PreauthHash[2] = SHA-512(PreauthHash[1] || SESSION_SETUP_REQUEST_bytes)
...

This hash chain serves as the KDF context for key derivation (see Key Derivation), binding the session keys to the exact negotiate exchange. Any man-in-the-middle modification of the negotiate messages produces different keys, causing authentication to fail.

DittoFS uses the following default preference order:

Cipher preference (configurable):

  1. AES-128-GCM (0x0002) — fastest on modern hardware with AES-NI
  2. AES-128-CCM (0x0001) — fallback for 3.0/3.0.2
  3. AES-256-GCM (0x0004) — higher security, slightly slower
  4. AES-256-CCM (0x0003) — highest security AES-CCM variant

Signing preference (configurable):

  1. AES-128-GMAC (0x0002) — fastest for 3.1.1
  2. AES-128-CMAC (0x0001) — required for 3.0+
  3. HMAC-SHA256 — legacy for 2.x clients

FSCTL_VALIDATE_NEGOTIATE_INFO (Downgrade Protection)

Section titled “FSCTL_VALIDATE_NEGOTIATE_INFO (Downgrade Protection)”

For SMB 3.0 and 3.0.2 (which lack the preauth integrity hash chain), the client sends an FSCTL_VALIDATE_NEGOTIATE_INFO IOCTL after tree connect. The server validates that the negotiate parameters match what was originally negotiated:

  • Client sends: Capabilities, GUID, SecurityMode, requested Dialects
  • Server compares against stored negotiate state
  • If any field mismatches: connection is dropped (potential MITM downgrade)
  • For SMB 3.1.1: this IOCTL is not needed (preauth hash provides stronger protection). DittoFS drops the TCP connection if a 3.1.1 client sends it, per MS-SMB2 Section 3.3.5.15.12.
NEGOTIATE Request (variable):
StructureSize: 36
DialectCount: N (number of dialects)
SecurityMode: flags (SIGNING_ENABLED, SIGNING_REQUIRED)
Reserved: 0
Capabilities: flags
ClientGuid: 16 bytes
NegContextOffset: offset to negotiate contexts (3.1.1 only)
NegContextCount: number of contexts (3.1.1 only)
Dialects[]: array of uint16 dialect revisions
NegContextList[]: padded negotiate context structures (3.1.1 only)
adapters:
smb:
# Dialect selection (optional, default: all supported)
min_dialect: "3.0" # Reject clients below this dialect
max_dialect: "3.1.1" # Maximum dialect to negotiate

Encrypted messages use the 0xFD 'S' 'M' 'B' magic (vs 0xFE 'S' 'M' 'B' for unencrypted):

Transform Header (52 bytes):
ProtocolID: 0xFD534D42 (4 bytes)
Signature: AES-GCM/CCM authentication tag (16 bytes)
Nonce: AES-GCM/CCM nonce (16 bytes, left-padded with zeros)
OriginalMessageSize: uint32 (4 bytes)
Reserved: uint16 (2 bytes)
Flags: uint16 (2 bytes) -- 0x0001 = encrypted
SessionId: uint64 (8 bytes)

The AAD (Additional Authenticated Data) for the AEAD cipher is the 20 bytes of the transform header starting from the Nonce field through SessionId (bytes 20-51). This ensures the session binding and message size cannot be tampered with.

CipherIDDefault ForKey SizeNonce SizeTag Size
AES-128-CCM0x0001SMB 3.0, 3.0.2128-bit11 bytes16 bytes
AES-128-GCM0x0002SMB 3.1.1128-bit12 bytes16 bytes
AES-256-CCM0x0003256-bit11 bytes16 bytes
AES-256-GCM0x0004256-bit12 bytes16 bytes

AES-GCM is preferred for SMB 3.1.1 due to hardware acceleration (AES-NI + CLMUL) on modern CPUs. AES-CCM is the mandatory cipher for SMB 3.0 and 3.0.2 compatibility.

Per-session encryption (Session.EncryptData): When mode is required, sessions negotiating SMB 3.x have SMB2_SESSION_FLAG_ENCRYPT_DATA set in the SESSION_SETUP response and all subsequent messages on the session are encrypted. When mode is preferred, AEAD keys are still derived for SMB 3.x sessions (so per-share encryption can use them), but the session flag is not set — whole-session message encryption is only enforced in required mode or for shares with encrypt_data=true.

Per-share encryption (Share.EncryptData): Individual shares can require encryption via the encrypt_data flag in share configuration. When set, SMB2_SHAREFLAG_ENCRYPT_DATA is returned in TREE_CONNECT response.

Guest sessions: Never encrypted because guest sessions have no session key for key derivation.


DialectAlgorithmKey Derivation
SMB 2.0.2HMAC-SHA256Direct from session key
SMB 3.0AES-128-CMACSP800-108 KDF
SMB 3.0.2AES-128-CMACSP800-108 KDF
SMB 3.1.1AES-128-GMAC (preferred) or AES-128-CMACSP800-108 KDF with preauth hash

AES-128-GMAC is the preferred signing algorithm for SMB 3.1.1 because it leverages the same GCM hardware acceleration as encryption. If a 3.1.1 client omits the SIGNING_CAPABILITIES negotiate context, the server defaults to AES-128-CMAC per specification.

The signing algorithm is determined by the negotiated dialect and negotiate contexts:

  1. SMB 2.0.2: Always HMAC-SHA256 (no negotiation)
  2. SMB 3.0/3.0.2: Always AES-128-CMAC (no negotiation)
  3. SMB 3.1.1 with SIGNING_CAPABILITIES: First mutually supported algorithm from server preference list
  4. SMB 3.1.1 without SIGNING_CAPABILITIES: Default to AES-128-CMAC

SP800-108 Counter Mode KDF for Signing Keys

Section titled “SP800-108 Counter Mode KDF for Signing Keys”

For SMB 3.0+, the signing key is derived from the session key using NIST SP800-108 in Counter Mode with HMAC-SHA256 as the PRF:

SigningKey = KDF(SessionKey, Label, Context)
Where:
PRF = HMAC-SHA256
Key = SessionKey (from authentication)
Label = "SMBSigningKey\0" (null-terminated)
Context = varies by dialect (see Key Derivation section)
  • NEGOTIATE: Never signed (no session key yet)
  • SESSION_SETUP: Final response can be signed (to prove server identity)
  • After SESSION_SETUP: All messages signed when signing is enabled for the session
  • Encrypted messages: Signing is redundant when encryption is active (AEAD provides integrity), but DittoFS still signs to match Windows Server behaviour

SMB3 uses NIST SP800-108 Counter Mode KDF with HMAC-SHA256 as the PRF to derive per-purpose cryptographic keys from the session key obtained during authentication.

KDF-HMAC-SHA256(Key, Label, Context):
i = 1
L = keyLength * 8 (in bits)
result = PRF(Key, i || Label || 0x00 || Context || L)
return result[0:keyLength]

Where || denotes concatenation and PRF is HMAC-SHA256.

Four keys are derived per session:

KeyLabel (null-terminated)Usage
SigningKey"SMBSigningKey\0"Message signing (HMAC/CMAC/GMAC)
EncryptionKey"SMBS2CCipherKey\0" (3.0) / "SMBServerEncryptionKey\0" (3.1.1)Server-to-client encryption
DecryptionKey"SMBC2SCipherKey\0" (3.0) / "SMBClientEncryptionKey\0" (3.1.1)Client-to-server decryption
ApplicationKey"SMBAppKey\0"Application-level use
DialectKDF Context
SMB 3.0"SmbSign\0" / "ServerIn \0" / "ServerOut\0" (fixed strings)
SMB 3.0.2Same as 3.0
SMB 3.1.1Preauth integrity hash value (SHA-512 hash chain output)

The use of the preauth integrity hash as KDF context in 3.1.1 is critical for security: it cryptographically binds the derived keys to the exact negotiate exchange, preventing downgrade attacks where a MITM strips security capabilities.

For 128-bit ciphers (AES-128-GCM, AES-128-CCM, AES-128-CMAC, AES-128-GMAC), the derived key is 16 bytes. For 256-bit ciphers (AES-256-GCM, AES-256-CCM), the derived key is 32 bytes; the session key is required to be at least 32 bytes (achieved by hashing with SHA-256 if needed).


FeatureLease V1 (SMB 2.1)Lease V2 (SMB 3.0+)
ParentLeaseKeyNot availableLinks child to parent directory lease
EpochNot availableMonotonic counter for stale break detection
Directory LeasesNot supportedRead-caching on directories
Create ContextSMB2_CREATE_REQUEST_LEASESMB2_CREATE_REQUEST_LEASE_V2
Grant: None -> R (shared read)
None -> RWH (exclusive, single opener)
Break: RWH -> RH (another client opens for read)
RWH -> None (another client opens for write)
RH -> R (handle caching revoked)
R -> None (all caching revoked)

Break is initiated by the server when a conflicting open arrives. The original client must acknowledge the break and flush cached data before the new open proceeds.

Each lease V2 has a monotonic epoch counter that increments on every state change. When a lease break notification is sent, it includes the current epoch. If the client sends a break acknowledgment with a stale epoch (lower than current), the server knows the client missed an intermediate break and can take corrective action.

Lease V2 includes a ParentLeaseKey that associates the file’s lease with its parent directory’s lease. When a file operation triggers a directory lease break, the server can identify which parent directory leases need to be broken by matching ParentLeaseKey values.

Directory leases grant Read-caching on a directory, allowing the client to cache directory listings locally:

  • Granted: When a client opens a directory with a lease V2 create context
  • Cached data: QUERY_DIRECTORY results are cached client-side
  • Break trigger: Any modification to the directory’s contents (create, delete, rename)
  • Break target: Always breaks to None (directory leases only support Read state)

Directory lease breaks are triggered by the MetadataService when CreateFile, RemoveFile, or Rename modifies a directory. The break flows through the LockManager.CheckAndBreakDirectoryCaching() method.


V1 durable handles (SMB 2.0.2+) require a batch oplock:

  • DHnQ (Durable Handle Request): Client requests a durable handle in CREATE
  • DHnC (Durable Handle Reconnect): Client reconnects to a preserved handle
  • Requirement: The file must have been opened with a batch oplock grant
  • Limitation: No idempotent reconnection (duplicate reconnects may fail)

V2 durable handles (SMB 3.0+) add CreateGuid for idempotent reconnection:

  • DH2Q (Durable Handle V2 Request): Client provides a CreateGuid (16-byte GUID)
  • DH2C (Durable Handle V2 Reconnect): Client provides CreateGuid for matching
  • No oplock requirement: V2 handles do not require batch oplock
  • Idempotent: Multiple reconnect attempts with the same CreateGuid succeed
  • Precedence: When both V1 and V2 create contexts are present, V2 takes precedence per MS-SMB2

V2 reconnect performs 14+ validation checks per MS-SMB2 specification:

  1. Look up handle by CreateGuid
  2. Verify handle is in disconnected/durable state
  3. Verify requesting user matches original creator
  4. Verify file name matches
  5. Verify session key hash matches (SHA-256 of signing key)
  6. Verify share name matches
  7. Verify handle has not timed out
  8. Verify no conflicting opens exist
  9. … (additional checks per spec)

If all checks pass, the handle is restored to the new session. The IsDurable flag is NOT set on the restored handle — the client must re-request durability after reconnect.

  • Default timeout: 60 seconds (configurable)
  • Scavenger interval: Periodic background goroutine scans for expired handles
  • Cleanup: Expired handles are cleaned up (pending I/O cancelled, locks released, handle removed from store)
  • Scavenger lifecycle: Tied to Serve context — stops automatically on adapter shutdown

V2 durable handles support an optional App Instance ID (16-byte GUID) for cluster failover scenarios. When a client reconnects from a different cluster node with the same App Instance ID, the server can close the old handle and transfer state to the new session.

DH2Q Create Context (Durable Handle V2 Request):
Timeout: uint32 (requested timeout in milliseconds)
Flags: uint32 (PERSISTENT flag for persistent handles)
Reserved: 8 bytes
CreateGuid: 16 bytes (client-generated GUID)
DH2C Create Context (Durable Handle V2 Reconnect):
FileId: 16 bytes (persistent + volatile)
CreateGuid: 16 bytes (must match original DH2Q)
Flags: uint32

Client Server
| |
|--- NEGOTIATE (SecurityBuffer) ---->|
|<-- NEGOTIATE Response (mechTypes) -|
| |
|--- SESSION_SETUP (SPNEGO Init) --->|
| Contains: mechToken (AP-REQ) |
| or NTLM Negotiate |
| |
|<-- SESSION_SETUP Response ---------|
| Contains: mechToken (AP-REP) |
| or NTLM Challenge |
| Status: MORE_PROCESSING (NTLM) |
| or SUCCESS (Kerberos) |
| |
[NTLM only: additional round-trip] |
|--- SESSION_SETUP (NTLM Auth) ----->|
|<-- SESSION_SETUP (SUCCESS) --------|

The SPNEGO wrapper advertises both Kerberos and NTLM mechanism OIDs. Clients with valid Kerberos tickets choose Kerberos for single round-trip authentication.

  1. Client obtains TGT from KDC, then requests service ticket for cifs/server.example.com@REALM
  2. Client sends AP-REQ inside SPNEGO InitToken in SESSION_SETUP
  3. Server validates AP-REQ against keytab, extracts session key
  4. Server sends AP-REP (mutual authentication) inside SPNEGO Response
  5. Session key from Kerberos is used as input to SP800-108 KDF for signing/encryption keys

The Kerberos session key (from AP-REQ validation) becomes the base session key for the SP800-108 KDF. This key is then used to derive:

  • SigningKey (for AES-CMAC/GMAC message signing)
  • EncryptionKey (for AES-GCM/CCM encryption)
  • DecryptionKey (for AES-GCM/CCM decryption)

When Kerberos is not available (no keytab configured, client has no valid TGT, or DNS resolution fails), the server falls back to NTLM authentication:

  1. Client sends NTLM Negotiate message
  2. Server responds with NTLM Challenge
  3. Client sends NTLM Authenticate with NTProofStr
  4. Server validates credentials: local users are checked against the stored NT hash; AD domain users are validated by forwarding the challenge/response to the Domain Controller over a sealed NETLOGON secure channel (requires a configured machine account)

NTLM provides weaker security than Kerberos: no mutual authentication, vulnerable to relay attacks, and the session key is derived from the password hash rather than a fresh Kerberos session key.

When authentication fails and guest access is enabled:

  • Session is created with guest privileges
  • No signing: Guest sessions cannot sign messages (no session key)
  • No encryption: Guest sessions cannot be encrypted (no key for KDF)
  • Security implications: guest access should be limited to read-only public shares

DittoFS supports simultaneous NFS and SMB access to the same files and directories. This section documents how the protocols interact through the unified LockManager in pkg/metadata/lock/.

The following table shows what happens when an operation from one protocol encounters active caching state from the other protocol:

NFS operation encountering SMB state:

NFS OperationSMB Read Lease (R)SMB Write Lease (RW/RWH)SMB Dir Lease
READCoexistsBreak to None, wait for ack
WRITEBreak to NoneBreak to None, wait for ack
CREATEBreak directory lease
REMOVEBreak to NoneBreak to None, wait for ackBreak directory lease
RENAMEBreak to None (src + dst)Break to None, wait for ackBreak both src and dst dir leases
LINKBreak target directory lease
SETATTR (file)Break to None
OPEN (delegation grant)Check coexistenceConflict: break lease first

SMB operation encountering NFS state:

SMB OperationNFS Read DelegNFS Write DelegNFS Dir Deleg
CREATE (read)CoexistsCB_RECALL, wait
CREATE (write)CB_RECALL, waitCB_RECALL, wait
WRITECB_RECALL, waitCB_RECALL, wait
DELETECB_RECALL, waitCB_RECALL, wait
RENAMECB_RECALL (src + dst)CB_RECALL, waitCB_RECALL both dirs
CREATE (in dir)CB_RECALL + CB_NOTIFY
DELETE (in dir)CB_RECALL + CB_NOTIFY
QUERY_DIR (lease req)Check coexistence
NFS StateSMB StateResultRationale
Read delegationRead lease (R)CoexistBoth are read-only caching; no data conflict
Read delegationWrite lease (RW/RWH)ConflictWrite lease allows cached writes that read delegation won’t see
Write delegationAny leaseConflictWrite delegation implies exclusive write caching
Any delegationWrite leaseConflictWrite lease implies exclusive write caching
Dir delegationDir leaseCoexistBoth are read-only directory caching

Break Flow: SMB Write Triggers NFS Delegation Recall

Section titled “Break Flow: SMB Write Triggers NFS Delegation Recall”
SMB Client LockManager NFS Client
| | |
|-- CREATE (write) ---------->| |
| |-- CheckAndBreakCachingForWrite |
| | find NFS read delegation |
| | mark delegation.Breaking |
| |-- OnDelegationRecall -------->|
| | (via NFSBreakHandler) |
| | CB_RECALL --->|
| | |
| |<---- DELEGRETURN -------------|
| | delegation removed |
|<-- CREATE response ----------| |

Break Flow: NFS Open Triggers SMB Lease Break

Section titled “Break Flow: NFS Open Triggers SMB Lease Break”
NFS Client LockManager SMB Client
| | |
|-- OPEN (write) ------------>| |
| |-- CheckAndBreakCachingForWrite |
| | find SMB RWH lease |
| | mark lease.Breaking |
| |-- OnOpLockBreak ------------->|
| | (via SMBBreakHandler) |
| | LEASE_BREAK ----->|
| | |
| |<---- LEASE_BREAK_ACK ---------|
| | lease downgraded/removed |
|<-- OPEN response ------------| |

When a file is created, deleted, or renamed, the MetadataService triggers directory caching breaks for the parent directory:

Any Client MetadataService LockManager
| | |
|-- CREATE file in /dir ------>| |
| |-- notifyDirChange("/dir") -->|
| | |
| | CheckAndBreakDirectoryCaching:
| | 1. Break SMB dir leases |
| | 2. Break NFS dir delegations
| | 3. Queue DirNotification |
| | (type=Add, name=file) |
| | |
| | Consumers: |
| | - SMB: CHANGE_NOTIFY |
| | - NFS: CB_NOTIFY |

RENAME across directories breaks both source and target directory leases and delegations.

To prevent rapid grant-break-grant-break cycles (lease/delegation storms), the LockManager maintains a unified recentlyBrokenCache with a configurable TTL (default 30 seconds):

  1. When a lease or delegation is broken, the file handle is marked in the cache
  2. Subsequent lease/delegation grant requests check the cache
  3. If the handle was recently broken, the grant is denied (client retries later)
  4. The TTL applies cross-protocol: an NFS delegation broken due to SMB activity prevents NFS re-grant for the TTL duration, and vice versa

Directory change notifications are queued in a bounded notification queue owned by the LockManager:

  • Capacity: 1024 events per directory (configurable)
  • Overflow: Collapses to a single “full rescan needed” event
  • Flush: Triggered by size threshold (100 events) or time threshold (500ms)
  • Consumers: NFS adapter drains into CB_NOTIFY; SMB adapter drains into CHANGE_NOTIFY
  • Event types: Add, Remove, Rename, Modify (with entry name and old/new names for rename)

Hidden files are handled differently between Unix and Windows:

  • Unix convention: Files starting with . are hidden
  • Windows convention: Files with the Hidden attribute flag are hidden

DittoFS bridges both conventions:

  • Dot-prefix files (.gitignore, .DS_Store) appear with FILE_ATTRIBUTE_HIDDEN in SMB listings
  • The Hidden attribute can also be explicitly set via SMB SET_INFO (FileBasicInformation)
  • Both conventions are persisted: dot-prefix detection is automatic, explicit Hidden flag is stored in metadata

Special Files (FIFO, Socket, Device Nodes)

Section titled “Special Files (FIFO, Socket, Device Nodes)”

Unix special files (FIFO, socket, block device, character device) have no meaningful representation in SMB:

  • Via NFS: Full support — MKNOD creates, GETATTR returns correct type
  • Via SMB: Hidden from directory listings entirely

This behaviour matches commercial NAS devices (Synology, QNAP) which typically do not expose special files via SMB.

Symlinks are handled transparently via MFsymlink format:

  • NFS-created symlinks: Appear as MFsymlink files (1067 bytes) when read via SMB
  • SMB-created symlinks: MFsymlink files are automatically converted to real symlinks on CLOSE
  • Both NFS and SMB clients can follow symlinks correctly

DittoFS implements SMB2 byte-range locking per [MS-SMB2] 2.2.26/2.2.27.

  • Shared (Read) Locks: Multiple clients can hold shared locks on overlapping ranges
  • Exclusive (Write) Locks: Only one client can hold an exclusive lock on a range
// Lock request processing
for each lockElement in request.Locks {
if lockElement.Flags & UNLOCK {
// Release lock - NOT rolled back on batch failure
store.UnlockFile(handle, sessionID, offset, length)
} else {
// Acquire lock - rolled back if later operation fails
store.LockFile(handle, lock)
acquiredLocks = append(acquiredLocks, lockElement)
}
}

Locks are enforced on READ/WRITE operations:

  • READ: Blocked by another session’s exclusive lock on overlapping range
  • WRITE: Blocked by any other session’s lock (shared or exclusive) on overlapping range

Same-session locks never block the owning session’s I/O operations.

Locks are ephemeral (in-memory only) and persist until:

  • Explicitly released via LOCK with SMB2_LOCKFLAG_UNLOCK
  • File handle is closed (CLOSE command)
  • Session disconnects (LOGOFF or connection drop)
  • Server restarts (all locks lost)

Per SMB2 specification ([MS-SMB2] 2.2.26):

  1. Unlock operations are NOT rolled back: If a batch LOCK request includes unlocks and a later lock acquisition fails, the successful unlocks remain in effect.

  2. Lock type changes: When re-locking an existing range with a different type (shared to exclusive), rollback removes the lock entirely rather than reverting to the original type.


DittoFS implements SMB2 opportunistic locks per [MS-SMB2] 2.2.14, 2.2.23, 2.2.24.

  • None (0x00): No caching allowed
  • Level II (0x01): Shared read caching — multiple clients can cache read data
  • Exclusive (0x08): Exclusive read/write caching — single client can cache reads and writes
  • Batch (0x09): Like Exclusive with handle caching — client can delay close operations
  1. Grant: Client requests oplock level in CREATE request
  2. Cache: Client caches file data according to granted level
  3. Break: When another client opens the file, server sends OPLOCK_BREAK notification
  4. Acknowledge: Original client flushes cached data and acknowledges break
// Level II allows multiple readers (first holder tracked)
clientA opens file -> granted Level II
clientB opens file (Level II) -> granted Level II (coexistence)
// Exclusive/Batch requires break on conflict
clientA opens file -> granted Exclusive
clientB opens file -> server initiates break to Level II
-> clientB gets None (must retry after break)

When an oplock break is initiated, the conflicting client is not granted an oplock immediately. It must retry after the break acknowledgment.

  • Leases preferred: SMB3 clients should use Lease V2 instead of traditional oplocks
  • In-memory tracking: Oplock state is lost on server restart
  • Single holder tracking: Only tracks one Level II holder (others coexist but are not tracked)

DittoFS implements CHANGE_NOTIFY support per [MS-SMB2] 2.2.35/2.2.36, with directory change events delivered through the unified notification queue.

The implementation accepts CHANGE_NOTIFY requests and delivers change events through the LockManager’s notification queue:

  • Watch Registration: Clients can register directory watches with completion filters
  • Change Detection: CREATE, CLOSE (delete-on-close), SET_INFO (rename), and cross-protocol operations trigger change events
  • Notification Queue: Events are queued and delivered to registered watchers via the LockManager
Client registers CHANGE_NOTIFY -> STATUS_PENDING
|
MetadataService detects change -> LockManager.notifyDirChange()
|
LockManager queues DirNotification -> flush to registered consumers
|
SMB adapter delivers CHANGE_NOTIFY response with FILE_NOTIFY_INFORMATION

The following filters are recognized:

FilterValueDescription
FILE_NOTIFY_CHANGE_FILE_NAME0x0001File create/delete/rename
FILE_NOTIFY_CHANGE_DIR_NAME0x0002Directory create/delete/rename
FILE_NOTIFY_CHANGE_ATTRIBUTES0x0004Attribute changes
FILE_NOTIFY_CHANGE_SIZE0x0008File size changes
FILE_NOTIFY_CHANGE_LAST_WRITE0x0010Last write time changes

Full async notification delivery requires:

  1. Connection-level async response infrastructure
  2. Message ID tracking for pending requests
  3. Proper SMB2 async response framing