Skip to content

feat(rs-sdk-ffi): expose dash_sdk_signer_can_sign for signer-delegated key preflight#3924

Merged
QuantumExplorer merged 1 commit into
v3.1-devfrom
claude/zealous-lederberg-ada608
Jun 16, 2026
Merged

feat(rs-sdk-ffi): expose dash_sdk_signer_can_sign for signer-delegated key preflight#3924
QuantumExplorer merged 1 commit into
v3.1-devfrom
claude/zealous-lederberg-ada608

Conversation

@QuantumExplorer

Copy link
Copy Markdown
Member

Issue being fixed or feature implemented

Follow-up to #3922 (review thread #3922 (comment)).

selectSigningKey(from:operation:) in StateTransitionExtensions.swift picks the public key for a document state transition by ranking candidates by purpose/security, then checking private-material availability against KeychainManager.shared (via KeyManager.withSharedKeychain().findSigningKey(...)). The six document SDK ops (documentCreate / Replace / Delete / Transfer / UpdatePrice / Purchase) each accept a generic signer: OpaquePointer, so a caller passing a signer not backed by the shared Keychain — a raw-key KeyManager.createSigner(from:) signer, a hardware-backed callback signer, or a KeychainSigner bound to a different KeychainManager — would have its key availability mis-judged, and the preflight could reject a key that signer can actually sign with.

It's a latent design coupling, not an active bug: every current caller passes a KeychainSigner defaulting to KeychainManager.shared. But it's exactly the coupling #3922's own doc-comment flagged for removal.

What was done?

Removed the coupling by delegating the availability decision to the supplied signer, while keeping key-selection policy (ranking by purpose/security) in Swift.

rs-sdk-ffi (signer.rs) — new export:

pub unsafe extern "C" fn dash_sdk_signer_can_sign(
    signer: *const SignerHandle,
    pubkey_bytes: *const u8,
    pubkey_len: usize,
    key_type: u8,
) -> bool

It reconstructs a minimal IdentityPublicKey from (pubkey_bytes, key_type) and calls the existing VTableSigner::can_sign_with (previously only invoked internally by Rust during signing). This naturally covers both inner variants: Inner::Callback forwards (pubkey_bytes, key_type) through the vtable's can_sign_with slot (same wire encoding as sign_async), and Inner::Native delegates to the wrapped Rust signer. KeyType::try_from(key_type) fails closed on any out-of-range byte (including the 0xFF platform-address tag); a null signer returns false. No private-key material crosses the boundary.

swift-sdk:

  • selectSigningKey now takes signer: OpaquePointer?. It ranks candidates via the new public KeyManager.rankSigningCandidates(...) and returns the first key the supplied signer reports it can sign with through a thin signerCanSign(_:_:) marshalling wrapper over dash_sdk_signer_can_sign.
  • The shared-Keychain findSigningKey path is retained as the fallback used only when no signer is available (signer == nil).
  • All six document ops pass their in-scope signer to selectSigningKey.
  • KeyManager.rankSigningCandidates(...) is a public, read-only ranking entry point delegating to the existing private rankKeys(...) (no duplication; stays off the @MainActor Keychain path).

The same purpose/security policy as before is preserved: contract create/update → AUTHENTICATION + minimum CRITICAL; document ops → AUTHENTICATION, prefer CRITICAL. Existing log markers are unchanged.

How Has This Been Tested?

  • cargo fmt --all clean; cargo check -p rs-sdk-ffi (incl. --all-targets) clean.
  • 4 new unit tests in signer.rs (can_sign_ffi_*): true/false vtable verdict round-trip, null signer → false, out-of-range key_type fails closed before the vtable is consulted. cargo test -p rs-sdk-ffi can_sign_ffi4 passed; full signer suite → 39 passed.
  • packages/swift-sdk/build_ios.sh succeeded; the new dash_sdk_signer_can_sign symbol is present in the generated rs-sdk-ffi.h. SwiftExampleApp built to BUILD SUCCEEDED, confirming the Swift wrapper, rankSigningCandidates, and rewired selectSigningKey compile against the regenerated header.

Breaking Changes

None. This adds a new FFI export and an additional signer: parameter on a private Swift helper (all in-tree call sites updated). Not consensus-breaking.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

🤖 Generated with Claude Code

…d key preflight

Follow-up to #3922 (review thread r3423222271). `selectSigningKey` picked a
document-signing key by ranking candidates by purpose/security and then
checking private-material availability against `KeychainManager.shared` (via
`KeyManager.withSharedKeychain().findSigningKey`). The six document ops accept
a generic `signer: OpaquePointer`, so a signer NOT backed by the shared
Keychain — a raw-key `KeyManager.createSigner` signer, a hardware-backed
callback signer, or a `KeychainSigner` bound to a different `KeychainManager` —
would have its key availability mis-judged. Latent (every current caller passes
a shared-Keychain `KeychainSigner`), but it's the coupling #3922 flagged for
removal.

Remove it by delegating the availability decision to the supplied signer:

- rs-sdk-ffi: new `dash_sdk_signer_can_sign(signer, pubkey_bytes, pubkey_len,
  key_type) -> bool`. Reconstructs a minimal IdentityPublicKey and calls the
  existing `VTableSigner::can_sign_with`, so it works for both inner variants
  (Callback forwards through the vtable's `can_sign_with` slot; Native
  delegates to the wrapped signer). `KeyType::try_from` fails closed on any
  out-of-range byte (incl. the 0xFF platform-address tag); null signer -> false.
- swift-sdk: `selectSigningKey` now takes `signer: OpaquePointer?`, ranks
  candidates in Swift via the new public `KeyManager.rankSigningCandidates`,
  and returns the first key the SUPPLIED signer reports it can sign with. The
  shared-Keychain `findSigningKey` path is kept as the no-signer fallback.
  Key-selection policy stays in Swift; only the availability check crosses the
  FFI. No private-key material is pulled across the boundary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@QuantumExplorer, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 13 minutes and 30 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 02a3194e-786e-404e-9d90-942044fe8291

📥 Commits

Reviewing files that changed from the base of the PR and between f1570bb and 1e884de.

📒 Files selected for processing (3)
  • packages/rs-sdk-ffi/src/signer.rs
  • packages/swift-sdk/Sources/SwiftDashSDK/FFI/StateTransitionExtensions.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/KeyManager.swift
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/zealous-lederberg-ada608

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@thepastaclaw

thepastaclaw commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

✅ Review complete (commit 1e884de)

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review

Both agents converged on no blocking issues. The new dash_sdk_signer_can_sign FFI export is sound (null-checks, fail-closed KeyType::try_from, no ownership transfer, no private material crossing the boundary), and the six Swift call-site rewires correctly delegate availability to the supplied signer. Two nitpicks remain: one about a documented-but-easy-to-misuse signer == nil fallback path in Swift, and one minor doc-contract loosening in the Rust FFI Safety comment.

💬 2 nitpick(s)

Comment on lines 54 to 119
@MainActor
private func selectSigningKey(from identity: DPPIdentity, operation: String) -> IdentityPublicKey? {
private func selectSigningKey(
from identity: DPPIdentity,
operation: String,
signer: OpaquePointer?
) -> IdentityPublicKey? {
print("🔑 [\(operation)] Selecting public key for identity \(identity.id.toBase58String())")

// Contract create/update require a CRITICAL + AUTHENTICATION key;
// document ops require any AUTHENTICATION key (prefer CRITICAL).
let isContractOp = operation == "CONTRACT CREATE" || operation == "CONTRACT UPDATE"
let minimumSecurityLevel: SecurityLevel? = isContractOp ? .critical : nil

let km = KeyManager.withSharedKeychain()

// Contract create/update require a CRITICAL + AUTHENTICATION key.
if operation == "CONTRACT CREATE" || operation == "CONTRACT UPDATE" {
if let k = km.findSigningKey(for: identity, purpose: .authentication, minimumSecurityLevel: .critical, preferCritical: true) {
print("📝 [\(operation)] Selected CRITICAL AUTHENTICATION key #\(k.id)")
// No signer to ask → fall back to the shared-Keychain availability check.
guard let signer = signer else {
if let k = km.findSigningKey(
for: identity,
purpose: .authentication,
minimumSecurityLevel: minimumSecurityLevel,
preferCritical: true
) {
if isContractOp {
print("📝 [\(operation)] Selected CRITICAL AUTHENTICATION key #\(k.id)")
} else {
print("📝 [\(operation)] Selected AUTHENTICATION key #\(k.id) (security \(k.securityLevel.name))")
}
return k
}
print("❌ [\(operation)] No CRITICAL AUTHENTICATION key with available private material")
if isContractOp {
print("❌ [\(operation)] No CRITICAL AUTHENTICATION key with available private material")
} else {
print("❌ [\(operation)] No AUTHENTICATION key with available private material")
}
return nil
}

// Document state transitions require an AUTHENTICATION-purpose key.
// Prefer a CRITICAL auth key, but findSigningKey skips any candidate
// whose private key is not in the Keychain, so an identity whose only
// CRITICAL key is a TRANSFER key (or a CRITICAL auth key with no stored
// private material) correctly falls through to its HIGH auth key.
if let k = km.findSigningKey(for: identity, purpose: .authentication, minimumSecurityLevel: nil, preferCritical: true) {
print("📝 [\(operation)] Selected AUTHENTICATION key #\(k.id) (security \(k.securityLevel.name))")
// Rank candidates by purpose/security in Swift, then delegate the
// availability decision to the supplied signer. Prefer a CRITICAL auth
// key, but skip any candidate the signer can't actually sign with, so an
// identity whose only CRITICAL key is a TRANSFER key (or a CRITICAL auth
// key with no stored private material) correctly falls through to its
// HIGH auth key.
let candidates = km.rankSigningCandidates(
for: identity,
purpose: .authentication,
minimumSecurityLevel: minimumSecurityLevel,
preferCritical: true
)
for k in candidates where signerCanSign(signer, k) {
if isContractOp {
print("📝 [\(operation)] Selected CRITICAL AUTHENTICATION key #\(k.id)")
} else {
print("📝 [\(operation)] Selected AUTHENTICATION key #\(k.id) (security \(k.securityLevel.name))")
}
return k
}
print("❌ [\(operation)] No AUTHENTICATION key with available private material")

if isContractOp {
print("❌ [\(operation)] No CRITICAL AUTHENTICATION key with available private material")
} else {
print("❌ [\(operation)] No AUTHENTICATION key with available private material")
}
return nil
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💬 Nitpick: signer == nil fallback silently reintroduces the KeychainManager.shared coupling this PR removes

All six in-tree callers of selectSigningKey (the document operations updated in this PR) pass a non-nil signer: OpaquePointer, so the guard let signer = signer else { ... } branch at lines 70–90 is unreachable today. The lines 51–53 docstring already calls out that passing nil falls back to KeyManager.findSigningKey(...) against KeychainManager.withSharedKeychain() — exactly the coupling the rest of the PR removes. Two cleaner options: (a) tighten the parameter to a non-optional OpaquePointer and delete the fallback so any future caller can't accidentally opt back into shared-Keychain semantics, or (b) leave the fallback but rename it so the foot-gun is explicit at the call site. Not a correctness defect for this PR, just a maintenance hazard.

source: ['claude']

Comment on lines +852 to +859
/// # Safety
/// - `signer` must be a valid `*const SignerHandle` previously returned by
/// this SDK (e.g. `dash_sdk_signer_create*` /
/// `dash_sdk_signer_create_from_private_key`) and not yet destroyed. It may
/// be null, in which case this returns `false`.
/// - `pubkey_bytes` must point to at least `pubkey_len` readable bytes, OR be
/// null with `pubkey_len == 0`. The bytes are only read for the duration of
/// this call and are not retained.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💬 Nitpick: Safety contract under-specifies the (non-null pubkey_bytes, pubkey_len == 0) case

The Safety section says pubkey_bytes must "point to at least pubkey_len readable bytes, OR be null with pubkey_len == 0". The Swift caller (signerCanSign) routes through Data.withUnsafeBytes { ... bindMemory(to: UInt8.self).baseAddress }, which for an empty Data may yield either nil or a non-null sentinel depending on Swift runtime. The implementation correctly handles both because std::slice::from_raw_parts(non_null, 0) is well-defined for any non-null aligned pointer, but the doc as written excludes the non-null/zero-length case. Loosening to "pointer is ignored when pubkey_len == 0" would match actual behavior. Pure documentation polish — no behavior change.

source: ['claude']

@codecov

codecov Bot commented Jun 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 72.78%. Comparing base (6a01c5b) to head (1e884de).
⚠️ Report is 6 commits behind head on v3.1-dev.

Additional details and impacted files
@@             Coverage Diff              @@
##           v3.1-dev    #3924      +/-   ##
============================================
+ Coverage     72.19%   72.78%   +0.59%     
============================================
  Files            21       22       +1     
  Lines          2946     3054     +108     
============================================
+ Hits           2127     2223      +96     
- Misses          819      831      +12     
Components Coverage Δ
dpp ∅ <ø> (∅)
drive ∅ <ø> (∅)
drive-abci ∅ <ø> (∅)
sdk ∅ <ø> (∅)
dapi-client ∅ <ø> (∅)
platform-version ∅ <ø> (∅)
platform-value ∅ <ø> (∅)
platform-wallet ∅ <ø> (∅)
drive-proof-verifier ∅ <ø> (∅)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@QuantumExplorer QuantumExplorer left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Reviewed

@QuantumExplorer QuantumExplorer merged commit 83fc8c3 into v3.1-dev Jun 16, 2026
19 of 20 checks passed
@QuantumExplorer QuantumExplorer deleted the claude/zealous-lederberg-ada608 branch June 16, 2026 21:14
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.

2 participants