Skip to content

fix(ruby): encrypt the record key to the owner public key on create - #1107

Merged
stas-schaller merged 1 commit into
release/sdk/ruby/v17.3.0from
feature/KSM-1193-ruby-record-key-wrap
Aug 12, 2026
Merged

stas-schaller merged 1 commit into
release/sdk/ruby/v17.3.0from
feature/KSM-1193-ruby-record-key-wrap

Conversation

@stas-schaller

@stas-schaller stas-schaller commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Ruby SDK: create_secret sent the record key to the server unencrypted. prepare_create_payload assigned the raw AES-256 record key to payload.record_key, while the same request carried the record data encrypted under that key. The server therefore received both a record's ciphertext and the key that opens it. This fix encrypts the record key to the application owner's public key first, matching the other SDKs.

Changes

Fixed

  • prepare_create_payload reads appOwnerPublicKey from storage and wraps the record key with Crypto.encrypt_ec before it goes on the wire (KSM-1193)
  • prepare_create_payload raises when the application configuration has no owner public key, instead of continuing without the encryption step (KSM-1193)

The wrap mirrors Python prepare_create_payload. Ruby's upload_file path already encrypted its file record key correctly, so only the create-record payload was affected.

The hard failure on a missing owner key is deliberate. A fallback to sending the key unwrapped would keep the defect for exactly the older applications most likely to lack that key.

Testing

cd sdk/ruby && bundle exec rspec

783 examples, 0 failures.

Three new examples in spec/keeper_secrets_manager/unit/create_secret_record_key_spec.rb assert the property on the payload bytes rather than on the code path:

  1. The value in payload.record_key decrypts back to the record key under the owner private key. A control assertion first proves the keypair and the two primitives round-trip, so a failure can only mean the wrap is absent.
  2. The record key genuinely decrypts payload.data, and the value on the wire is not those bytes. The first half stops the second from passing when the fixture is unrelated.
  3. Deleting appOwnerPublicKey makes the call raise rather than send the key unwrapped.

All three fail on the unfixed code. The second reports expected and actual as byte-identical 32-byte strings.

Five existing create_secret examples shared a config fixture that had no owner public key, which is why this shipped twice with a green suite. The fixture now holds a real P-256 key. No assertion in those examples changed.

Live check against QA created a record, confirmed payload.recordKey is 125 bytes beginning with 0x04 rather than the 32-byte plaintext key, read the record back with its title and field values intact, then deleted the record.

Breaking Changes

None for callers whose application has an owner public key, which is every application bound by a current Web Vault or Commander. An application whose configuration lacks appOwnerPublicKey now receives an error from create_secret instead of creating a record. That path previously produced records with an unwrapped key, so it was never safe to use.

Related Issues

  • Jira: KSM-1193

prepare_create_payload assigned the raw AES-256 record key to payload.record_key
with no encryption, while the same request carried the record data encrypted
under that key. The server therefore received both a record's ciphertext and the
key that opens it, which breaks the zero-knowledge property for every record the
Ruby SDK creates. Present since 17.1.0, so 17.1.0 and 17.2.0 are both published
with it.

The fix reads appOwnerPublicKey from storage, fails if it is absent, and wraps
the record key with Crypto.encrypt_ec, mirroring Python prepare_create_payload.
Ruby's upload_file path already did this correctly, so only the create-record
payload was affected. The hard failure is deliberate: a fallback to sending the
key unwrapped would leave the hole open for exactly the older applications most
likely to lack the key.

Three regression tests assert the property on the payload bytes rather than on
the code path. The second one is the defect stated as an assertion: it first
confirms the record key really does decrypt payload.data, then requires that the
value on the wire is not those bytes. The third forbids the silent-fallback fix.
Before the fix all three fail, and the second reports expected and actual as
byte-identical 32-byte strings.

Five existing create_secret specs shared a config fixture with no owner public
key, which is why the defect shipped twice with a green suite. The fixture now
carries a real P-256 key. No assertion in those specs changed.

Verified: 783 examples, 0 failures. Live check against QA created a record,
confirmed payload.recordKey is 125 bytes beginning with 0x04 rather than the
32-byte plaintext key, read the record back with its field values intact, and
deleted it.
@stas-schaller
stas-schaller merged commit b21dcf5 into release/sdk/ruby/v17.3.0 Aug 12, 2026
5 checks passed
@stas-schaller
stas-schaller deleted the feature/KSM-1193-ruby-record-key-wrap branch August 12, 2026 21:10
stas-schaller added a commit that referenced this pull request Sep 15, 2026
* fix(ruby): encrypt the record key to the owner public key on create (#1107)

prepare_create_payload assigned the raw AES-256 record key to payload.record_key
with no encryption, while the same request carried the record data encrypted
under that key. The server therefore received both a record's ciphertext and the
key that opens it, which breaks the zero-knowledge property for every record the
Ruby SDK creates. Present since 17.1.0, so 17.1.0 and 17.2.0 are both published
with it.

The fix reads appOwnerPublicKey from storage, fails if it is absent, and wraps
the record key with Crypto.encrypt_ec, mirroring Python prepare_create_payload.
Ruby's upload_file path already did this correctly, so only the create-record
payload was affected. The hard failure is deliberate: a fallback to sending the
key unwrapped would leave the hole open for exactly the older applications most
likely to lack the key.

Three regression tests assert the property on the payload bytes rather than on
the code path. The second one is the defect stated as an assertion: it first
confirms the record key really does decrypt payload.data, then requires that the
value on the wire is not those bytes. The third forbids the silent-fallback fix.
Before the fix all three fail, and the second reports expected and actual as
byte-identical 32-byte strings.

Five existing create_secret specs shared a config fixture with no owner public
key, which is why the defect shipped twice with a green suite. The fixture now
carries a real P-256 key. No assertion in those specs changed.

Verified: 783 examples, 0 failures. Live check against QA created a record,
confirmed payload.recordKey is 125 bytes beginning with 0x04 rather than the
32-byte plaintext key, read the record back with its field values intact, and
deleted it.

* docs(ruby): correct the affected version list for the record key fix

The entry named 17.1.0 and 17.2.0. All four published versions carry the
defect, including 17.0.3 and 17.0.4.

Verified by downloading each gem from RubyGems and reading
prepare_create_payload: the unwrapped assignment is present in every one,
byte-identical. It is also present in the SDK's first commit, d026001, so it
was never correct rather than introduced by a later change. The git history
misled the original entry because the Ruby SDK's history in this repository
begins at the v17.1.0 release, four months after 17.0.3 had already published.

This matters to a reader rather than only to the record: naming two versions
implicitly cleared the other two, so a customer on 17.0.4 would have taken a
security note as an all-clear.

* chore(ruby): set version to 17.2.1 for the record key security patch

* docs(ruby): address review feedback on the 17.2.1 changelog and gemspec

- move KSM-1193 CHANGELOG entry under ### Security (matches file's own
  precedent for security items)
- clarify 17.0.0-17.0.2 were never published to RubyGems
- scope "existing records" wording to records created by an earlier version
- add missing [17.2.1] compare link
- drop dates from all version headers in CHANGELOG.md/README.md
- README: document create_secret's new raise and the re-bind/env-var remedy
- gemspec: fix changelog_uri to point at sdk/ruby/CHANGELOG.md, not a
  nonexistent repo-root file
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant