Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions stellar/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions stellar/wraith-names/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ soroban-sdk = { workspace = true }
[dev-dependencies]
proptest = "1.6.0"
soroban-sdk = { workspace = true, features = ["testutils"] }
ed25519-dalek = "2"
proptest = "1"
279 changes: 279 additions & 0 deletions stellar/wraith-names/audits/2026-05-author.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
# Wraith Names — Stellar Contract Audit

**Contract:** `stellar/wraith-names/src/lib.rs` (~363 LOC contract logic, ~340 LOC tests)
**Auditor:** AI Security Audit
**Date:** 2026-05-31
**Severity Matrix:** Critical / High / Medium / Low / Informational

---

## Executive Summary

The `WraithNames` Stellar contract implements an on-chain registry mapping `.wraith` names to stealth meta-addresses. It supports registration, update, release, resolution, reverse lookup, and on-behalf (relayed) operations with Ed25519 signature authorization.

**Findings:** 1 Medium, 2 Low, 3 Informational. No Critical or High severity issues found.

The contract correctly enforces ownership via Soroban's `Address` auth system, validates name characters and meta-address length, and properly maintains bidirectional name↔meta-address mappings. The primary divergence from the EVM reference is the ownership model (Soroban Address vs. secp256k1 spending key) and meta-address length (64 bytes Ed25519 vs. 66 bytes secp256k1), which are chain-appropriate design decisions rather than bugs.

---

## Findings

### [MEDIUM] Update events use `register` symbol — indistinguishable from registrations

**Location:** `lib.rs:262-265` (`update_internal`)

```rust
env.events().publish(
(symbol_short!("register"), name_hash), // should be "update"
(name, new_meta_address),
);
```

The `update_internal` function publishes an event with the `register` symbol, identical to the event published by `register_internal` at line 211. External consumers (indexers, frontends, analytics) cannot distinguish name updates from new registrations by inspecting events.

**Impact:** Off-chain services that track name lifecycle will misclassify updates as registrations. This could lead to incorrect statistics, duplicate entries in indexes, or incorrect UI state.

**Recommendation:** Use a distinct symbol such as `symbol_short!("update")` for the update event, or better yet, emit a separate `NameUpdated` event type with the old and new meta-addresses.

---

### [LOW] Replay protection stored in instance storage with TTL

**Location:** `lib.rs:95, 130, 159`

On-behalf replay keys are stored via `env.storage().instance().set(...)`. In Soroban, instance storage is subject to TTL (time-to-live) and can be archived. If the contract's instance storage TTL expires without renewal, all replay protection data is lost, enabling signature replay attacks.

**Impact:** If the contract instance storage expires, previously-used on-behalf signatures could be replayed, allowing duplicate registrations or updates.

**Recommendation:**
1. Ensure the contract includes a `extend_ttl` function to renew instance storage
2. Consider using persistent (contract) storage for replay keys with explicit TTL management
3. Document the instance TTL requirements for deployers

**Note:** In practice, Soroban contracts are typically called frequently enough that instance storage TTL is renewed automatically, but this should not be relied upon for security-critical data.

---

### [LOW] On-behalf authorization message does not include owner public key

**Location:** `lib.rs:369-386` (`authorization_message`)

The authorization message is constructed from:
- Domain (`wraith-names:v1`)
- Operation (`wraith-names:register`, etc.)
- Name (raw bytes)
- Stealth meta-address (raw bytes)
- Expiry (u64 big-endian)

The owner's Ed25519 public key is NOT included in the signed message. While the signature is verified against the owner's public key (line 325), the message hash itself does not bind to the owner's identity.

**Impact:** If the same Ed25519 keypair is used across multiple Wraith contracts or chains, a signature intended for one context could theoretically be replayed in another. The domain string (`wraith-names:v1`) provides partial protection, but does not include a chain identifier or contract ID.

**Recommendation:** Include `owner_public_key` bytes in the authorization message to fully bind the signature to a specific owner:

```rust
message.extend_from_slice(&public_key.to_array()); // Add owner pubkey
```

---

## Safety Analysis

### Name Squatting / Front-Running

**Risk:** Low on Stellar (vs. High on EVM)

Soroban's transaction ordering model differs from EVM:
- Transactions are not ordered by gas price bidding in a public mempool
- Soroban does not expose a public mempool with the same visibility as Ethereum
- Validators do not typically engage in MEV extraction at the individual transaction level

**However**, within a single ledger, transaction ordering can still be manipulated by validators. An attacker who controls or influences block production could observe a registration transaction in the ledger and insert their own registration for the same name in the same ledger before it.

**Mitigation assessment:** The contract has no commit-reveal mechanism. For a high-value name registry, a two-phase commit-reveal pattern could be considered:
1. `commit(nameHash, salt)` — stores commitment
2. `reveal(name, salt)` — validates and registers

This is not implemented. Given Soroban's reduced MEV surface, this is an acceptable risk for initial deployment but should be documented.

### Ownership Transfer

**Finding:** Correctly implemented.

The `update_internal` function at line 238 checks `entry.owner != owner` before allowing the update. The `owner` parameter is the address that called `require_auth()` in the public wrapper. This means:

- Only the current owner (Soroban Address) can authorize an update
- The update is atomic: old reverse lookup is deleted (line 246-248), new entry is written (line 255), new reverse lookup is created (line 257-260) — all within the same transaction
- If any step fails, the entire transaction reverts

**Edge case:** If `owner` is a Soroban contract (not an account), contract-based authentication applies. The contract correctly handles this via Soroban's auth system.

### Release Flow

**Finding:** Correctly implemented. No cooldown is enforced.

When a name is released (`release_internal`):
1. Ownership is verified (line 280-282)
2. Reverse lookup is deleted (line 284-285)
3. Name entry is deleted (line 286)

After release, any party can immediately re-register the name (tested at `lib.rs:528-532`). No cooldown period exists.

**Assessment:** No cooldown is by design and is acceptable. A cooldown would create a denial-of-service vector (an attacker could release a name they don't own — wait, they can't, because ownership is verified). The current design is correct: only the owner can release, and after release, the name is free for anyone.

**Consideration:** If a user accidentally releases a name, there is no recovery mechanism. This is a UX consideration, not a security issue.

### Reverse-Lookup Poisoning

**Finding:** Correctly implemented. Reverse lookups are properly garbage-collected.

When Alice registers `alice` → meta_A:
- `Reverse(sha256(meta_A))` → `hash("alice")`

When Alice updates to meta_B:
- `Reverse(sha256(meta_A))` is deleted
- `Reverse(sha256(meta_B))` → `hash("alice")`

When Alice releases:
- `Reverse(sha256(meta_A))` is deleted
- Name entry is deleted

If Alice transfers her stealth meta-address to Bob's keys (i.e., Alice calls `update` with Bob's new meta-address):
- Old reverse lookup for Alice's old meta-address is deleted
- New reverse lookup points to Alice's name with Bob's meta-address
- `name_of(bob_meta_address)` correctly returns "alice"

This is correct behavior: the reverse lookup always reflects the current mapping. If Bob wants to register "alice" under his own Soroban address, he must either:
1. Have Alice release the name first, then register it himself
2. Have Alice update the meta-address to his (the name stays "alice" but now resolves to Bob's keys)

### Hash Collisions

**Finding:** No implementation bugs detected. SHA-256 is used correctly.

The `hash_name` function (line 359-367):
1. Copies the name bytes into a buffer
2. Creates a `Bytes` slice of the correct length
3. Computes SHA-256 of the name bytes
4. Returns the full 32-byte hash as `BytesN<32>`

There is no truncation. The full SHA-256 output is used as the storage key. Collision probability is 2^-256, which is negligible.

**Note:** The reverse lookup also uses `BytesN::from_array(env, &env.crypto().sha256(&stealth_meta_address).to_array())` (line 206, 242-245, 257, 284), which is the full 32-byte hash. No truncation.

---

## Correctness Analysis

### Name Validation

**Finding:** Correctly implemented per spec (3-32 chars, lowercase alphanumeric).

The `validate_name` function (line 389-410):
- Rejects names < 3 characters → `NameTooShort`
- Rejects names > 32 characters → `NameTooLong`
- Rejects any byte outside `a-z` (0x61-0x7a) and `0-9` (0x30-0x39) → `InvalidNameCharacter`

**Edge cases verified:**
- Empty string: rejected (length 0 < 3)
- 2-character name: rejected (length 2 < 3)
- 3-character name "abc": accepted
- 32-character name: accepted
- 33-character name: rejected
- Uppercase "Alice": rejected (0x41 is not in range)
- Underscore "user_name": rejected (0x5F is not in range)
- Hyphen "user-name": rejected (0x2D is not in range)
- Unicode characters: rejected (multi-byte UTF-8 will have bytes outside the valid range)
- ASCII control bytes (0x00-0x1F): rejected
- Space character (0x20): rejected

**Note:** Soroban's `String` type stores UTF-8. The validation operates on raw bytes, so multi-byte UTF-8 characters will be rejected because their continuation bytes (0x80-0xBF) fall outside the valid range. This is correct behavior.

### Meta-Address Validation

**Finding:** Correctly enforced. Length is checked at every entry point.

The meta-address length check (`stealth_meta_address.len() != 64`) is performed in:
- `register_internal` (line 187-189)
- `update_internal` (line 225-227)
- `register_on_behalf` → via `register_internal`
- `update_on_behalf` → via `update_internal`

The meta-address must be exactly 64 bytes, which corresponds to two Ed25519 public keys (32 bytes each): spending_pubkey || viewing_pubkey.

**Note:** This differs from the EVM reference, which expects 66 bytes (two secp256k1 compressed keys, 33 bytes each). See the cross-chain divergence table below.

### Authorization

**Finding:** Correctly implemented for all state-mutating functions.

| Function | Auth Check | Notes |
|---|---|---|
| `register` | `owner.require_auth()` | Caller must be the registering address |
| `register_on_behalf` | Ed25519 signature verification | Signature from owner's signing key |
| `update` | `owner.require_auth()` + `entry.owner != owner` check | Must be current owner |
| `update_on_behalf` | Ed25519 signature + ownership check via `update_internal` | Signature from current owner |
| `release` | `owner.require_auth()` + `entry.owner != owner` check | Must be current owner |
| `release_on_behalf` | Ed25519 signature + ownership check via `release_internal` | Signature from current owner |

All view functions (`resolve`, `name_of`) correctly require no auth.

**Note:** The EVM reference uses a different authorization model — signatures from the secp256k1 spending key embedded in the meta-address. The Stellar contract uses Soroban's `Address` auth system, which is chain-appropriate.

---

## Cross-Chain Divergence Table

| Aspect | EVM (`WraithNames.sol`) | Stellar (`WraithNamesContract`) | Impact |
|---|---|---|---|
| **Meta-address length** | 66 bytes (secp256k1 compressed: 33+33) | 64 bytes (Ed25519: 32+32) | Chain-appropriate; different key types |
| **Ownership model** | Derived from spending pubkey (secp256k1) in meta-address; `ecrecover` verifies | Soroban `Address` auth system; `require_auth()` verifies | **Semantic divergence**: EVM ties ownership to the spending key; Stellar ties ownership to the Soroban address. A user who changes their Soroban address loses ownership in Stellar. In EVM, ownership follows the spending key. |
| **Registration** | `register(name, metaAddress, signature)` — single function | `register(owner, name, metaAddress)` — auth via `require_auth()` | Different API; both are secure |
| **Registration on-behalf** | `registerOnBehalf(name, metaAddress, signature)` — nonce-based replay protection | `register_on_behalf(owner, name, metaAddress, signature, expiry)` — expiry + hash-based replay | Different replay protection mechanism |
| **Update** | `update(name, newMetaAddress, signature)` — signed by current spending key | `update(owner, name, newMetaAddress)` — auth via `require_auth()` | Same ownership check, different auth mechanism |
| **Release** | `release(name, signature)` — signed by spending key | `release(owner, name)` — auth via `require_auth()` | Same ownership check, different auth mechanism |
| **Reverse lookup** | `nameOf(metaAddress)` — returns empty string if not found | `name_of(metaAddress)` — returns `NameNotFound` error if not found | **API divergence**: EVM returns empty string; Stellar returns error |
| **Name hashing** | `keccak256(bytes(name))` | `sha256(name_bytes)` | Different hash functions; no collision risk |
| **Name validation** | 3-32 chars, lowercase alphanumeric | 3-32 chars, lowercase alphanumeric | **Consistent** |
| **Update event** | Emits `NameRegistered` (same event for register and update) | Emits `register` symbol for both register and update | **Consistent behavior**, but Stellar has the Medium issue noted above (same symbol for both) |
| **Release event** | Emits `NameReleased` | Emits `release` symbol | Consistent |
| **Spending address storage** | Stored in `NameEntry.spendingAddress` (derived from pubkey) | Not stored; ownership is the Soroban `Address` | **Architectural divergence** |
| **Cooldown after release** | None | None | **Consistent** |

---

## Mainnet Readiness

### Go/No-Go: **GO** (with recommended fixes)

The Stellar `WraithNames` contract is **mainnet-ready** with the following conditions:

#### Must Fix Before Mainnet
- **[MEDIUM] Update event symbol**: Change `symbol_short!("register")` in `update_internal` to a distinct symbol like `symbol_short!("update")`. This is required for correct off-chain indexing.

#### Recommended Before Mainnet
- **[LOW] Replay protection storage**: Ensure the contract has a mechanism to extend instance storage TTL, or migrate replay keys to persistent storage.
- **[LOW] Bind owner pubkey in authorization message**: Include the owner's public key in the signed message for on-behalf operations to prevent cross-protocol replay.

#### Nice to Have
- Add a `contract_version()` view function for future upgrade tracking
- Consider a `batch_register_on_behalf` function for gas efficiency
- Document the instance storage TTL requirements for deployers

#### Known Trade-offs
- **No commit-reveal for front-running**: Acceptable for Stellar's reduced MEV surface but should be documented
- **No cooldown after release**: By design; accidental releases are irreversible
- **Ownership tied to Soroban Address**: Users who lose access to their Soroban address cannot recover their names (same as losing a private key on EVM)

---

## Summary of Issues

| Severity | Count | Description |
|---|---|---|
| Critical | 0 | — |
| High | 0 | — |
| Medium | 1 | Update event symbol collision |
| Low | 2 | Replay storage TTL; Authorization message binding |
| Informational | 3 | No commit-reveal; No cooldown; Address-based ownership |
Loading