Class: RKSeal::SecureWorkspace

Inherits:
Object
  • Object
show all
Defined in:
lib/rkseal/secure_workspace.rb

Overview

Provides a RAM-backed scratch path for the plaintext edit buffer and guarantees its destruction. This is the single enforcement point for the hard rule: **plaintext must never touch persistent disk.**

The medium is chosen per-OS behind one interface:

- Linux: a tmpfs path (`/dev/shm`, or `$XDG_RUNTIME_DIR`).
- macOS: an ephemeral `hdiutil`-backed RAM disk, attached for the duration
  of the edit and detached afterwards (macOS has no tmpfs/`/dev/shm`).

There is **no on-disk ‘mktemp` fallback**. If a RAM-backed medium cannot be provisioned, the workspace raises WorkspaceError rather than degrade the security guarantee.

The public API is block-scoped so callers cannot forget teardown: the path exists only inside the block, and on block exit (normal, exception, or signal) the file is best-effort shredded/overwritten and unlinked and any RAM disk is detached. Signal handling and ‘at_exit` registration guard against a crash leaking a mounted RAM disk. Secret values are never logged.

rubocop:disable Metrics/ClassLength – this single class deliberately holds the workspace orchestration plus its two tightly-coupled per-OS medium strategies (LinuxMedium, MacosMedium). They are one cohesive unit and the gem keeps one layer per file, so splitting them out would scatter the “never on disk” guarantee rather than clarify it.

Defined Under Namespace

Classes: LinuxMedium, MacosMedium

Constant Summary collapse

FILE_MODE =

Filesystem permissions for the scratch file: owner read/write only.

0o600
DIR_MODE =

Permissions for the (Linux) scratch directory: owner only.

0o700
RAM_DISK_BYTES =

Size of the macOS RAM disk. A few MB is plenty for a Secret manifest; the buffer holds one small YAML document, never bulk data.

8 * 1024 * 1024
SECTOR_BYTES =

Bytes per disk sector, used to convert RAM_DISK_BYTES into the sector count ‘hdiutil attach ram://<sectors>` expects.

512
TRAPPED_SIGNALS =

Signals whose default action would terminate the process before ‘ensure` blocks normally run; we trap them so teardown still fires.

%w[INT TERM].freeze

Class Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(basename: "rkseal") ⇒ SecureWorkspace

Returns a new instance of SecureWorkspace.

Parameters:

  • basename (String) (defaults to: "rkseal")

    hint for the scratch file name (no secret data).



145
146
147
148
149
# File 'lib/rkseal/secure_workspace.rb', line 145

def initialize(basename: "rkseal")
  @basename = sanitize_basename(basename)
  @medium = build_medium
  @path = nil
end

Class Attribute Details

.registryObject (readonly)

Returns the value of attribute registry.



61
62
63
# File 'lib/rkseal/secure_workspace.rb', line 61

def registry
  @registry
end

.registry_mutexObject (readonly)

Returns the value of attribute registry_mutex.



61
62
63
# File 'lib/rkseal/secure_workspace.rb', line 61

def registry_mutex
  @registry_mutex
end

Class Method Details

.register(workspace) ⇒ void

This method returns an undefined value.

Register a workspace in the process-wide safety net and lazily install the ‘at_exit` hook and signal traps on first use.

Parameters:



85
86
87
88
89
90
# File 'lib/rkseal/secure_workspace.rb', line 85

def register(workspace)
  @registry_mutex.synchronize do
    install_safety_net unless @safety_net_installed
    @registry << workspace unless @registry.include?(workspace)
  end
end

.unregister(workspace) ⇒ void

This method returns an undefined value.

Drop a workspace from the safety net once it has torn itself down.

Parameters:



96
97
98
# File 'lib/rkseal/secure_workspace.rb', line 96

def unregister(workspace)
  @registry_mutex.synchronize { @registry.delete(workspace) }
end

.with(basename: "rkseal") {|path| ... } ⇒ Object

Provision a RAM-backed scratch file, yield its path, and guarantee teardown when the block returns or raises.

Parameters:

  • basename (String) (defaults to: "rkseal")

    hint for the scratch file name (no secret data).

Yield Parameters:

  • path (String)

    absolute path to the RAM-backed file.

Yield Returns:

  • (Object)

    whatever the block returns is returned to caller.

Returns:

  • (Object)

    the block’s return value.

Raises:

  • (RKSeal::WorkspaceError)

    if a RAM-backed medium cannot be provisioned or mounted (never falls back to plain on-disk temp).



72
73
74
75
76
77
78
# File 'lib/rkseal/secure_workspace.rb', line 72

def with(basename: "rkseal")
  workspace = new(basename: basename)
  path = workspace.provision
  yield path
ensure
  workspace&.teardown
end

Instance Method Details

#provisionString

Provision the RAM-backed medium and return the usable scratch path. The caller is then responsible for invoking #teardown (prefer the block-scoped with which does this automatically).

Returns:

  • (String)

    absolute path to the RAM-backed file.

Raises:



157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
# File 'lib/rkseal/secure_workspace.rb', line 157

def provision
  return @path if @path

  # Register in the crash-safety net BEFORE touching any RAM medium, so a
  # SIGINT/SIGTERM landing mid-provision -- e.g. after `hdiutil attach`
  # succeeds but during `newfs_hfs`/`mount` -- still sweeps this workspace
  # and detaches the attached device. The sweep would otherwise miss a
  # half-provisioned workspace and leak an orphaned RAM device. Teardown is
  # idempotent and tolerates any partial state, so early registration is
  # safe and `unregister` on the rescue/teardown path keeps it accurate.
  self.class.register(self)

  directory = @medium.provision
  @path = File.join(directory, "#{@basename}-#{SecureRandom.hex(8)}")
  create_scratch_file(@path)
  @path
rescue WorkspaceError
  teardown
  raise
rescue StandardError => e
  teardown
  raise WorkspaceError, "failed to provision RAM-backed workspace: #{e.message}"
end

#teardownvoid

This method returns an undefined value.

Best-effort shred + unlink the scratch file and detach/teardown any RAM disk. Idempotent and must not raise on a partially-provisioned workspace (it runs from ‘ensure`/signal paths). Logs nothing sensitive.



186
187
188
189
190
191
192
193
194
195
196
197
# File 'lib/rkseal/secure_workspace.rb', line 186

def teardown
  shred_and_unlink(@path)
  @path = nil
  @medium.teardown
  self.class.unregister(self)
  nil
rescue StandardError
  # Teardown is a safety net and must never raise. We have already nulled
  # @path so a retry is harmless; the RAM medium's own teardown retries a
  # transiently-busy detach internally.
  nil
end