4

I have several encrypted APFS volumes, some of which have their passphrases stored only in specific users' keychains so they can unlock and mount automatically for those users.

However, when the user logs out, the volume remains mounted. While this isn't strictly a problem (as I set mount points inside the user's home folder, and have volume ownership enabled), I would prefer for the volume to unmount and lock again automatically.

This behaviour differs compared to mounting an encrypted disk image, which is mounted as a specific user (when viewing the output of mount) and which then (normally*) unmounts when the user logs out.

Is it possible to replicate the same behaviour with APFS volumes, i.e- mount automatically for user with the passphrase in their keychain when they login, and then unmount (and lock) automatically when they logout?

*I say normally, as macOS Catalina appears to have bugs related to cleaning up user processes when a user logs out, resulting in many processes still running for logged out users. This currently includes disk-image-helper, so it no longer unmounts automatically as it did under Mojave. I'm happy to accept any answer that replicates the disk image mounting/unmounting behaviour with the caveat that this currently doesn't always work for Catalina, on the basis that it should work if Apple ever fixes these bugs.

Update: I've tried doing the following in a script triggered by launchd, but launchd's kill signal doesn't appear to reach it:

#!/bin/bash
VOLUME=12345678-9012-3456-7890-123456789012
MOUNT_POINT=/Users/haravikk/Desktop/Foo

[ ! -e "${MOUNT_POINT}" ] && { mkdir "${MOUNT_POINT}" || exit 1; }

if echo -e "$(security find-generic-password -wa "${VOLUME}" | sed 's/../\\x&/g')" | diskutil apfs unlockVolume "${VOLUME}" -stdinpassphrase; then
    cleanup() {
        echo 'Unmounting'
            
        attempts=5
        while [[ ${attempts} -gt 0 ]]; do
            diskutil apfs lockVolume "${VOLUME}" && break
            [[ -n "${MOUNT_POINT}" ]] && umount "${MOUNT_POINT}" && break
            attempts=$((${attempts} - 1))
            sleep 5
        done
        if [[ ${attempts} = 0 ]]; then
            if ! diskutil unmount force "${VOLUME}"; then
                if [[ -z "${MOUNT_POINT}" ]] || ! umount -f "${MOUNT_POINT}"; then
                    echo 'All attempts to unmount failed' >&2
                fi
            fi
        fi
    }
    trap 'cleanup' SIGINT SIGHUP SIGTERM EXIT
    while true; do
        sleep 86400 &
        wait $!
    done
fi

The idea was that when the user logs out, launchd should send their processes a kill signal (SIGINT) which will trigger the script's trap and allow it to unmount the volume. But this never seems to happen; the trap isn't triggered at all.

If anyone is interested in using the basics of this script, note that you will need to have an entry in your keychain for the volume (you can do this by mounting with Disk Utility and choosing to save when prompted for the password), and must make sure that security has permission to access it.

1
  • Are you sure the deamon is configured correctly? Do you log some things so see how far it gets if it gets called at all?
    – user14492
    Commented Jul 19, 2020 at 21:29

2 Answers 2

1

After much experimentation I've arrived at a scripted solution, with a script that can operate in two modes:

The first is as a daemon, usually run as root (as launch daemon), which listens on a given socket for commands identifying the volume you want to mount (must be unmounted), followed by another to confirm that you mounted it (proving you can), and a third to then unmount it, force unmount it, or clear the request. The behaviour is a bit simplistic, but should reasonably establish a client had the ability to mount the volume, and therefore is allowed to request that it then be unmounted, using a simple random credential.

When not run in daemon mode, the script takes a volume identifier (anything supported by diskutil apfs unlockVolume, UUIDs preferred) and attempts to unlock and mount the volume. You need to have the password for the volume in the keychain for the user running the script, and will be prompted to allow security to access it. The script normally attempts to unmount a volume by itself, however I've established that most of the time this won't work, as disk arbitration is usually unloaded before the script attempts to do so (meaning diskutil unmount and umount both fail), as such if you want to use this script with a launch agent that will unmount on logout, you need to have a daemon running on the same system and set the --socket argument to match.

MountAPFS

Hopefully this is fairly clear in how it's supposed to be used, as it includes examples and options are documented. This is not intended for anyone that doesn't have some grasp of Terminal usage and shell scripting (ZSH specifically) as you may need to customise it to do exactly what you want.

#!/bin/zsh
{

# Examples:
#   Standalone: ./MountAPFS 12345678-9012-3456-7890-12345678901234
#      (mount): ./MountAPFS --create ~/Library/Volumes/Foo 12345678-9012-3456-7890-12345678901234
#
#   Daemon:     ./MountAPFS --daemon --socket 61616
#   Client:     ./MountAPFS --socket 61616 12345678-9012-3456-7890-12345678901234

while [ $# -gt 0 ]; do
    case "$1" in
        # Set a directory that needs to be created (usually the volume's mount point when a custom mount point is specified in /etc/fstab)
        ('--create'|'--create-dir'|'--create-directory')
            CREATE_DIRECTORY="$2"; shift
            case "${CREATE_DIRECTORY:0:1}" in
                ('/')   ;;
                ('~')   CREATE_DIRECTORY="${HOME}${CREATE_DIRECTORY:1}"   ;;
                (*)     CREATE_DIRECTORY="${BASE_DIRECTORY}/${CREATE_DIRECTORY}"  ;;
            esac
        ;;
        # Runs this script in daemon mount (do not mount any volumes, instead handle the unmount of registered volumes on behalf of other tasks).
        ('--daemon') DAEMON=1 ;;
        # The socket to listen on/connect to when working in/with a daemon script
        ('--socket') SOCKET="$2"; WAIT=1; shift ;;
        # The amount of time to wait for the volume to become available before giving up. This option can be used if there may be a race condition between this and another task before the volume becomes available
        ('--timeout') TIMEOUT="$2"; shift ;;
        # Do not end once the volume is mounted, instead wait for a termination signal and attempt to unmount it
        ('--wait') WAIT=1 ;;
        # Enable verbose output; this will output volume identifiers and tokens for tracing, but will only output the last four characters of tokens to prevent abuse (full tokens are 32 characters in length)
        ('-v'|'--verbose') VERBOSITY=$(($(echo "0${VERBOSITY}" | sed 's/[^0-9]*//g') + 1)) ;;
        # Explicit end of arguments
        ('--') shift; break ;;
        (--*) echo "Unknown option: $1" >&2; exit 2 ;;
        # Implicit end of arguments (first volume)
        (*) break ;;
    esac
    shift
done

VERBOSITY=$(echo "0${VERBOSITY}" | sed 's/[^0-9]*//g')

if [[ -n "${SOCKET}" ]]; then
    [[ "${SOCKET}" = "$(echo "${SOCKET}" | sed 's/[^0-9]*//g')" ]] || { echo 'Invalid socket:' "${SOCKET}" >&2; exit 2; }
    [[ "${SOCKET}" -gt 0 ]] || { echo 'Invalid socket:' "${SOCKET}" >&2; exit 2; }
fi

if [ "${DAEMON}" = 1 ]; then
    [[ -n "${SOCKET}" ]] || { echo 'Daemon mode requires a socket' >&2; exit 2; }

    # Open netcat on the specified socket
    coproc nc -kl localhost "${SOCKET}" || { echo 'Unable to open socket' >&2; exit 2; }
    trap 'coproc :' EXIT SIGHUP SIGINT SIGTERM
    
    [[ ${VERBOSITY} -gt 0 ]] && echo 'APFS daemon listening on socket:' "${SOCKET}"
    
    declare -A requested=()
    declare -A mounted=()
    while IFS='', read -rd '' line; do
        cmd="${line:0:5}"
        value="${line:5}"
        case "${cmd}" in
            # Indicates intention to mount a current unmounted volume (given in value).
            # Returns a token that must be used in future commands
            ('mount')
                if mount=$(diskutil info "${value}" 2>/dev/null | grep 'Mounted' | sed 's/[^:]*: *//') && [[ "${mount}" = 'No' ]]; then
                    token=$(echo "${value}$(head -c 512 </dev/urandom)" | md5)
                    requested[${token}]=${value}
                    printf '%s%s\0' 'mount' "${token}" >&p

                    [[ ${VERBOSITY} -gt 0 ]] && echo 'Accepted mount request for:' "${value} assigned token ending with:" "${token: -4}"
                else
                    printf '%s%s\0' 'error' 'Volume not found, or is already mounted' >&p
                    [[ ${VERBOSITY} -gt 0 ]] && echo 'Volume not found or already mounted:' "${value}" >&2
                fi
            ;;
            # Indicates that the previously registered volume is now mounted. Volume is identified using the unique token returned by the mount command. Now that the volume has been mounted, it can be unmounted using the unmnt or funmt command.
            # Returns the volume that was tested
            ('mnted')
                volume=${requested[$value]}
                if [ -n "${volume}" ]; then
                    if mount=$(diskutil info "${volume}" 2>/dev/null | grep 'Mounted' | sed 's/[^:]*: *//') && [[ "${mount}" != 'No' ]]; then
                        mounted[${value}]=${volume}
                        unset "requested[${token}]"
                        printf '%s%s\0' 'mnted' "${volume}" >&p

                        [[ ${VERBOSITY} -gt 0 ]] && echo 'Confirmed mounting of:' "${volume} using token ending with:" "${value: -4}"
                    else
                        printf '%s%s\0' 'error' 'Volume not found, or is not mounted' >&p
                        [[ ${VERBOSITY} -gt 0 ]] && echo 'Volume not found or not mounted:' "${volume}" >&2
                    fi
                else
                    printf '%s%s\0' 'error' 'Unknown token: use the mount command first' >&p
                    [[ ${VERBOSITY} -gt 0 ]] && echo "Received ${cmd} command out of sequence or invalid token ending with: ${token: -4}" >&2
                fi
            ;;
            # Requests that a previously mounted volume to be unmounted. Volume is identified using the unique token used in the mnted command.
            # The funmt command will attempt to forcibly unmount the volume, and should only be used if the unmnt command previously failed.
            # Returns the volume that was unmounted
            ('unmnt'|'funmt')
                volume=${mounted[$value]}
                if [ -n "${volume}" ]; then
                    if mount=$(diskutil info "${volume}" 2>/dev/null | grep 'Mounted' | sed 's/[^:]*: *//') && [[ "${mount}" != 'No' ]]; then
                        [ "${cmd}" = 'funmt' ] && force='force ' || force=''
                    
                        if error=$(diskutil unmount ${force}"${volume}" 2>&1); then
                            unset "mounted[${token}]"
                            printf '%s%s\0' "${cmd}" "${volume}" >&p

                            [[ ${VERBOSITY} -gt 0 ]] && echo 'Unmounted volume:' "${volume} using token ending with:" "${token: -4}"
                        else
                            printf '%s%s\0' 'error' "Unable to unmount ${volume}: ${error}" >&p
                            [[ ${VERBOSITY} -gt 0 ]] && echo 'Unable to mount:' "${volume}: ${error}" >&2
                        fi
                    else
                        printf '%s%s\0' 'error' 'Volume not found, or is not mounted' >&p
                        [[ ${VERBOSITY} -gt 0 ]] && echo 'Volume not found:' "${volume}" >&2
                    fi
                else
                    printf '%s%s\0' 'error' 'Unknown token: use the mnted command first' >&p
                    [[ ${VERBOSITY} -gt 0 ]] && echo "Received ${cmd} command out of sequence: expected mnted" >&2
                fi
            ;;
            # Clear a token that is no longer needed
            ('clear')
                unset "requested[${value}]"
                unset "mounted[${value}]"
                
                printf '%s%s\0' 'clear' "${value}" >&p
                
                [[ ${VERBOSITY} -gt 0 ]] && echo 'Cleared token ending with:' "${value: -4}"
            ;;
            # Unknown command
            (*)
                printf '%s%s\0' 'error' "Unknown command: ${cmd}" >&p
                [[ ${VERBOSITY} -gt 0 ]] && echo 'Received unknown command:' "${cmd}" >&2
            ;;
        esac
    done <&p

    coproc :
    [[ ${VERBOSITY} -gt 0 ]] && echo 'Terminating.'
else
    [[ -z "${BASE_DIRECTORY}" ]] && BASE_DIRECTORY="${HOME}/Library/Vaults/"
    [[ -d "${BASE_DIRECTORY}" && -w "${BASE_DIRECTORY}" ]] || { echo 'Missing or unwritable base directory:' "${BASE_DIRECTORY}" >&2; exit 1; }

    [[ $# -lt 1 ]] && { echo 'Missing volume' >&2; exit 1; }
    VOLUME="$1"

    # If a timeout was given, wait until the volume is ready
    TIMEOUT=$(echo "${TIMEOUT}" | sed 's/[^0-9]*//g')
    if [[ -n "${TIMEOUT}" ]]; then
        while [[ "${TIMEOUT}" -gt 0 ]]; do
            diskutil info "${VOLUME}" 2>&1 >/dev/null && break
            TIMEOUT=$((${TIMEOUT} - 5))
            sleep 5
        done
    fi

    # Make sure the volume is available to be unlocked
    error=$(diskutil info "${VOLUME}" 2>&1) || { echo 'Volume not found:' "${VOLUME}:" "${error}" >&2; exit 3; }

    # If a mount point was given, try to create a directory (otherwise volume won't mount over it)
    if [[ -n "${CREATE_DIRECTORY}" ]] && [[ ! -d "${CREATE_DIRECTORY}" ]]; then
        error=$(mkdir -m 700 "${CREATE_DIRECTORY}") || { echo 'Unable to create mount point:' "${CREATE_DIRECTORY}:" "${error}" >&2; exit 4; }
    fi
    
    # If a socket was given, register our intention to mount the volume
    token=
    if [[ "${WAIT}" = 1 && -n "${SOCKET}" ]]; then
        socket_cmd() { local cmd="$1"; local value="$2"
            coproc nc localhost "${SOCKET}" || { echo 'Unable to connect to socket' >&2; return 1; }
            
            local response=
            printf '%s%s\0' "${cmd}" "${value}" >&p
            read -rd '' response <&p
            
            case "${response:0:5}" in
                ("${cmd}")
                    printf '%s' "${response:5}"
                    coproc :
                    return 0
                ;;
                ('error')
                    echo "socket_cmd() error: ${response:5}" >&2
                    coproc :
                    return 2
                ;;
                (*)
                    echo 'Unknown/unsupported response:' "${response}" >&2
                    coproc :
                    return 3
                ;;
            esac
        }
        token=$(socket_cmd 'mount' "${VOLUME}") || SOCKET=
    fi

    if error=$(echo -e "$(security find-generic-password -wa "${VOLUME}" | sed 's/../\\x&/g')" | diskutil apfs unlockVolume "${VOLUME}" -stdinpassphrase) || error2=$(diskutil mount "${VOLUME}"); then
        if [[ "${WAIT}" = 1 ]]; then
            # Confirm mounting of volume to socket (if registered)
            [[ -n "${token}" ]] && { volume_confirm=$(socket_cmd "mnted" "${token}") || token=; }
        
            printf '%s' 'Awaiting signal... '
        
            # Trap and wait until task is ended, then lock the volume
            cleanup_run=0
            cleanup() {
                [[ ${cleanup_run} = 0 ]] || return 0
                cleanup_run=1
            
                echo 'received.'
                printf '%s' 'Unmounting... '
                
                attempts=5
                while [[ ${attempts} -gt 0 ]]; do
                    diskutil apfs lockVolume "${VOLUME}" >/dev/null && echo 'done.' && break
                    [[ -n "${CREATE_DIRECTORY}" ]] && umount "${CREATE_DIRECTORY}" && echo 'done.' && break
                    [[ -n "${token}" ]] && volume_confirm=$(socket_cmd 'unmnt' "${token}") && token= && echo 'done.' && break
                    attempts=$((${attempts} - 1))
                    sleep 5
                done
                if [[ ${attempts} = 0 ]]; then
                    if diskutil unmount force "${VOLUME}" >/dev/null; then
                        echo 'forced.'
                    else
                        if [[ -z "${CREATE_DIRECTORY}" ]] || ! umount -f "${CREATE_DIRECTORY}"; then
                            if [[ -z "${token}" ]] || ! volume_confirm=$(socket_cmd 'funmt' "${token}"); then
                                echo 'failed.'
                                echo 'All attempts to unmount failed' >&2
                            else
                                token=
                                echo 'forced.'
                            fi
                        else
                            echo 'forced.'
                        fi
                    fi
                fi
                [[ -n "${token}" ]] && socket_cmd 'clear' "${token}"
                
                # Clear all background tasks
                coproc :
                [[ -n "${${(v)jobstates##*:*:}%=*}" ]] && kill ${${(v)jobstates##*:*:}%=*}
            }
            trap 'cleanup' SIGINT SIGHUP SIGTERM EXIT
            while true; do
                sleep 86400 &
                wait $!
            done
        fi
    else
        echo 'Unable to mount volume:' "${error}" "${error2}" >&2
        [[ -n "${token}" ]] && socket_cmd 'clear' "${token}"
    fi
fi

}
0

You might try using a logout hook. Login and logout hooks are deprecated, but I believe they still function.

Mounting the volume should not be a problem; a user LaunchAgent would handle that nicely. The problem lies in trying to unmount the volume at logout. Have you considered writing a system LaunchDaemon that periodically polls the open APFS volumes and unmounts those that don't have an associated user? The volumes should be automatically locked by system security when they are unmounted, so I don't think you need to make a special effort for that, and it sounds like you're thinking more about cleanup than anything else. If you put the daemon on (say) a 30 second timer, it shouldn't consume too much in the way of resources, and volumes will only persist for an average of 15 second after logout.

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .