diff --git a/Documentation/Compatibility/BRC-Matrix.md b/Documentation/Compatibility/BRC-Matrix.md index f72f73f..b2d852f 100644 --- a/Documentation/Compatibility/BRC-Matrix.md +++ b/Documentation/Compatibility/BRC-Matrix.md @@ -43,6 +43,7 @@ | BRC-84 `key-derivation/0084.md` | Linked derivation using HMAC keyed by serialized counterparty public key rather than ECDH shared secret. | **None direct** | `BSVKeys` optional derivation mode | BRC-42/43 primitives. | Inline examples. | Security/domain-separation choice; do not silently fold into Type-42. | BRC-86 `key-derivation/0086.md` | Bidirectionally authenticated/restricted Type-42 setup and privacy controls. | **None direct** | `BSVAuth` + `BSVKeys` | BRC-42/43 and peer authentication. | Protocol walkthrough. | Higher-layer handshake, not primitive derivation parity. | BRC-140 `key-derivation/0140.md` | Shamir sharing over secp256k1 field prime **p** (not group order n); a CSPRNG-defined interpolation polynomial; HMAC-SHA512-derived share x-coordinates; shares encoded `base58(x).base58(y).threshold.integrity`; integrity is first 8 hex chars of HASH160(compressed pubkey); Lagrange reconstruction; `n>=2`, `m>=2`, `n<=m`. | **Full**: `ec.PrivateKey.ToPolynomial`, `ToKeyShares`, `ToBackupShares`, `PrivateKeyFromKeyShares`, `PrivateKeyFromBackupShares`; `primitives/keyshares.KeyShares`, `Polynomial`. | `BSVCrypto`, `BSVKeys` | Field arithmetic mod p, HMAC-SHA512, HASH160, Base58, secure random. | BRC shows share/recovery examples; generation is randomized, so use an injected deterministic-randomness harness plus reconstruction differentials. | BRC describes counter material `[i, attempt, 32 random bytes]` without integer widths/endian; Go uses 4-byte BE `i`, 4-byte BE `attempt`, then 32 bytes. Freeze that for parity or obtain clarification. +| BRC-157 `key-derivation/0157.md` | Treat validated BIP-39 entropy as the backup subject; left-pad shorter entropy to a 32-byte BRC-140 key; retain or infer the original entropy length; derive profiles at hardened `m/0'/i'` from the BIP-39 seed with an empty passphrase by default. | **Composed primitives**: Go `compat/bip39`, `compat/bip32`, and `ec.PrivateKey` BRC-140 methods; TypeScript `Mnemonic`, `HD`, and `PrivateKey` methods. | `BSVCompat`, using `BSVKeys` BRC-140 | BRC-32/75/140, secp256k1 scalar validation. | Worked 12-word/root vector in BRC plus independently derived profile and all-word-count vectors. | Trusted, authenticated entropy-length metadata is required for exact restoration of imported 12–21-word mnemonics, and wallet restore should verify the expected identity because an oversized supported length is intrinsically ambiguous. The leading-zero heuristic is explicitly last-resort. A nonempty passphrase is a separate unrecoverable secret and must remain opt-in. | BRC-30 `transactions/0030.md` | Extended Format: marker `0000000000EF` after version; each input additionally includes source satoshis and source locking script, permitting offline fee/signature verification. | **Full with spec-width mismatch**: `transaction.Transaction.ReadFrom` EF detection; `Transaction.EF`, `EFHex`; extended input parsing. | `BSVTransaction` | BRC-12, amount type, script codec. | Go EF tests and document examples. | BRC says source satoshis is 4 bytes, but Go reads/writes uint64 (8 bytes). Bitcoin amounts and de-facto SDK behavior favor 8; raise an erratum and gate Swift parity on 8 unless project explicitly chooses strict prose. | BRC-58/61/71 `transactions/{0058,0061,0071}.md` | Historical Merkle path JSON and binary encodings; reverse displayed txid/hash bytes as required; SHA256d branches; CMP aggregation. | **Legacy/indirect**: current `transaction.MerklePath` primarily reflects BRC-74. | `BSVSPV` compatibility codecs only | SHA256d, CompactSize, byte order. | Document examples. | Treat as import compatibility, not the canonical in-memory/public serialization model. | BRC-74 `transactions/0074.md` | Unified BUMP binary: blockHeight CompactSize, treeHeight byte ≤64; each level nLeaves; leaf offset CompactSize; flags 0=data, 1=duplicate/no hash, 2=txid hash; 32-byte hashes. JSON `{blockHeight,path}`. Compute root; merge only equal-height/equal-root paths; deduplicate while retaining txid flags. | **Full**: `transaction.MerklePath`, `PathElement`, `NewMerklePathFromBinary`, `NewMerklePathFromHex`, `NewMerklePathFromReader`, `Bytes`, `ComputeRoot`, `Verify`, `Combine`, `MerkleTreeParent`. | `BSVSPV` | BRC-12 byte primitives, SHA256d, transaction-id byte order. | Exact BRC hex and expected root `57aab6…83b4`; same fixture is in `transaction/merklepath_test.go`. | BRC pseudocode’s `if (!index)` would reject valid index 0; prose/format and Go support index 0. Implement semantic rule, not that typo. diff --git a/Documentation/Compatibility/Rulings.md b/Documentation/Compatibility/Rulings.md index 5dca17c..be6fa9d 100644 --- a/Documentation/Compatibility/Rulings.md +++ b/Documentation/Compatibility/Rulings.md @@ -41,7 +41,7 @@ behind the general rule. | COMP-031 | Go `NewSymmetricKey` left-pads every key shorter than 32 bytes, including an empty key, retains oversized keys until AES reports an error, and `NewSymmetricKeyFromString` terminates the process on malformed Base64. | Swift accepts 1...32 input bytes, preserves the interoperable left-zero-padding rule, rejects empty and oversized keys immediately, parses only strict standard-padded Base64 with typed errors, and redacts default stringification. Encryption delegates to Swift Crypto AES-GCM with a fresh 32-byte nonce and emits the exact Go `nonce || ciphertext || 16-byte tag` envelope without AAD. | Empty all-zero keys and process termination are unsafe construction artifacts, not required wire behavior. Unit tests cover every relevant key length, malformed Base64, randomness, empty plaintext, truncation, and tampering. Persistent pinned-Go tests compare deterministic envelopes and decrypt in both directions, including 31-byte coordinate-style keys. | | COMP-032 | The Bitcoin Signed Message compatibility wire format defines compact headers `27...34`, while Go's address-verification helper derives a mainnet address regardless of the caller's intended network. | Swift accepts only headers `27...34`, preserves their recovery and compression bits, and verifies the recovered key against a supplied `Address` on that address's network. | The signature wire format is network-neutral; address encoding is not. Tests cover all eight valid headers, adjacent invalid values, mainnet and testnet verification, bitcoinj vectors, and bidirectional pinned-Go signing and recovery. | | COMP-033 | Pinned Go Electrum ECIES infers embedded versus omitted sender-key layout from packet length, and Bitcore compatibility derives keys from the minimal unsigned shared X coordinate while its default encryption path uses an all-zero IV. | Swift requires callers to select the Electrum embedded or omitted layout explicitly, bounds packet and ciphertext parsing, and authenticates with constant-time HMAC comparison before decryption. The ordinary encryption APIs always generate a fresh ephemeral sender key. Deterministic caller-supplied sender keys and Bitcore IVs are exposed only through explicitly named `encryptCompatibility` seams that warn against reuse. | Explicit layouts avoid ambiguous length heuristics without changing valid packets, while the obvious encryption path is safe against accidental key reuse. Unit and pinned-Go differential tests cover both Electrum layouts, Bitcore minimal-X derivation, zero- and explicit-IV envelopes, truncation, malformed keys, bad block lengths, and authentication failures. | -| COMP-034 | BRC-140 and pinned Go perform secret sharing with variable-time big-integer interpolation over the secp256k1 field prime, and recovery can otherwise ignore shares beyond the threshold. | Swift uses the BRC-140 field prime, accepts only canonical bounded share text, caps splitting and recovery at 20 shares, and validates every extra share against the threshold-sized interpolation basis. Secret-bearing text is available only through the explicit `backupString` export; normal and debug descriptions are redacted. | Variable-time interpolation is limited to offline backup workflows and is not presented as safe for attacker-observable online use. Tests cover canonical round trips, configuration and CPU bounds, malformed or inconsistent shares, extra-share validation, integrity checks, and redaction. | +| COMP-034 | BRC-140, pinned Go, and TypeScript reconstruct from the first threshold shares and ignore later shares; coordinate selection accepts only a candidate resolved within five attempts. | Swift follows the same first-threshold rule after requiring common threshold and integrity fields across the supplied set, and checks duplicate x-coordinates among the shares actually used. It accepts only canonical bounded share text, caps splitting and recovery at 20 shares, and performs exactly five coordinate attempts. Secret-bearing text is available only through the explicit `backupString` export; normal and debug descriptions are redacted. | This replaces the former Swift-only extra-share consistency policy with exact BRC-140 recovery semantics. Callers that want to try or compare different quorums must select and order them explicitly. Tests cover canonical round trips, five-attempt exhaustion, configuration and CPU bounds, malformed shares, first-quorum duplicate rejection, ignored extras, integrity checks, and redaction. | | COMP-035 | Swift's synthesized diagnostics and reflection can expose stored secret fields even when `description` is redacted, and the initial WIF façade returned the private-key encoding from `description`. | Every public value that stores private-key, symmetric-key, mnemonic, DRBG, extended-private-key, WIF, or key-share material supplies redacted string, debug, and custom-mirror output. Secret serialization remains available only through explicitly named exports such as `bytes`, `phrase`, `serialized`, `encoded`, and `backupString`. | Ordinary interpolation, `dump`, `Mirror`, debugger diagnostics, and logging must not become accidental key-export APIs. Tests capture each diagnostic path, require an empty custom mirror, and prove known secret fragments are absent while explicit exports retain exact wire compatibility. | | COMP-036 | Pinned Go's BRC-77 parser accepts trailing ASN.1 bytes and its ECDSA verifier accepts mathematically valid high-S signatures. The anyone recipient is encoded by one zero discriminator and uses the protocol's scalar-one derivation key. | Swift requires one complete, canonical strict-DER signature. A canonical high-S signature remains parseable but verification returns `false`; signatures created by either SDK are low-S. Anyone mode consumes exactly one zero byte and ignores any supplied recipient private key. | Canonical packets remain bidirectionally compatible while parser ambiguity and the existing strict ECDSA policy stay visible. Unit and pinned-Go tests cover trailing data, non-minimal DER, high-S, anyone packets, and recipient-specific packets. | | COMP-037 | Retrying a transaction POST can duplicate an accepted submission, while a provider response cannot establish which transaction the client serialized. Cancellation or transport failure after a POST starts does not prove that the provider rejected it. | WhatsOnChain chain-tracker GETs retry only bounded transient failures and honor a capped `Retry-After`. Broadcast POSTs are never retried, even under a policy with additional attempts. Swift computes the transaction ID locally, requires one canonical lowercase provider ID that matches it, rejects redirects and oversized responses, and redacts transaction material from error excerpts. | Read retries remain bounded and broadcast side effects are not duplicated automatically. Broadcast failure after dispatch is reported as failure with uncertain delivery; callers must reconcile the transaction ID before resubmitting. Tests cover retry classes and bounds, cancellation, strict responses, ID mismatch, redaction, and exactly one POST. | @@ -70,6 +70,7 @@ behind the general rule. | COMP-060 | Pinned Go certificate exchange does not enforce requested field or type completeness, does not correlate a response with one pending request, and carries an unsigned requested-certificate HTTP header. | Swift provides bounded typed post-authentication certificate requests and responses. It signs canonical request or response JSON, correlates one exact pending request, and requires separate BRC-52 certificate validation. The HTTP header remains rejected. | Canonical signed BRC-103 messages remain compatible. Swift does not accept missing disclosures, unsolicited responses, or unsigned HTTP certificate requests. | | COMP-061 | Pinned Go overlay HTTP and policy use unbounded response reads, unsafe binary counts and indexes, default trackers, unbounded fan-out, and scheduler-dependent result order. | Swift places bounded HTTPS facilitators in `BSVNetwork` and deterministic resolver and one-shot broadcaster policy in `BSVOverlay`. All hosts and limits are explicit, discovery verifies signed tokens, and failed submission has uncertain delivery. | Preserves canonical SHIP and SLAP requests and results without hidden network policy, unsafe retry, unbounded work, or nondeterministic aggregation. | | COMP-062 | Pinned Go provides one concrete UHRP downloader with internal resolver, tracker, and HTTP policy. | Swift keeps UHRP values in `BSVStorage` and places the injected resolver and bounded HTTPS downloader in `BSVNetwork`. It validates the exact four-field advertisement, binds it to the requested identifier, sorts hosts, makes one GET per host, and verifies the SHA-256 result. | Preserves the canonical `ls_uhrp` query and advertisement layout without hidden trackers, cleartext hosts, unbounded bodies, automatic retry, or mutable network policy. | +| COMP-063 | BRC-157 composes BIP-39, BIP-32, and BRC-140 without storage, transport, or a concrete wallet implementation. Go and TypeScript expose the constituent behavior from their SDK packages. | Swift places the composed entropy-backup and hardened-profile API in opt-in `BSVCompat`, which already owns BIP-39 and BIP-32 and depends on `BSVKeys` for BRC-140. It remains outside the modern `BSV` umbrella. | Keeps the reusable backup primitive in the SDK while preserving the explicit compatibility boundary required by BIP-39 and BIP-32. Toolbox wallets may consume it without owning or duplicating the primitive. | ## Scope decisions diff --git a/Documentation/Planning/Roadmap.md b/Documentation/Planning/Roadmap.md index 7943c03..430791e 100644 --- a/Documentation/Planning/Roadmap.md +++ b/Documentation/Planning/Roadmap.md @@ -71,10 +71,12 @@ canonical parsing and bounded offline interpolation. Accepted checkpoint: `BSVKeys` contains bounded WIF and P2PKH addresses for mainnet and testnet. The opt-in `BSVCompat` product contains BIP-32 hierarchical deterministic keys, English BIP-39 mnemonics with the fixed standard PBKDF2 -profile, Bitcoin Signed Message, and Electrum- and Bitcore-compatible ECIES. +profile, BRC-157 entropy-rooted mnemonic and share backups, Bitcoin Signed +Message, and Electrum- and Bitcore-compatible ECIES. - WIF and P2PKH addresses. (Accepted) - BIP-32 and BIP-39. (Accepted) +- BRC-157 entropy-rooted backup and profile derivation. (Accepted) - Bitcoin Signed Message. (Accepted) - Electrum- and Bitcore-compatible ECIES. (Accepted) diff --git a/README.md b/README.md index 3627363..ffdc57f 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,7 @@ print(script.isPayToPublicKeyHash) - Supports BRC-42 child keys and BRC-94 shared-secret proofs. - Supports WIF and P2PKH addresses. - Supports BRC-140 key shares for offline backups. +- Supports BRC-157 entropy-rooted mnemonic and share backups through opt-in `BSVCompat`. ### Wallets, messages, and network services @@ -223,7 +224,7 @@ print(script.isPayToPublicKeyHash) | `BSVCrypto` | Supplies hashes, symmetric cryptography, key derivation functions, and random data. | | `BSVKeys` | Supplies secp256k1 keys and signatures, ECDH, key tweaks, WIF, addresses, BRC-42, BRC-94, and BRC-140. | | `BSVMessage` | Supplies bounded BRC-77 signed messages and BRC-78 encrypted messages. | -| `BSVCompat` | Supplies opt-in BSM, ECIES, BIP-32, and BIP-39 compatibility APIs. | +| `BSVCompat` | Supplies opt-in BSM, ECIES, BIP-32, BIP-39, and BRC-157 backup APIs. | | `BSVScript` | Supplies Script data, BIP-276, opcodes, ASM, numbers, and templates. | | `BSVKVStore` | Supplies bounded, transport-neutral, Go-compatible one-field key-value tokens. | | `BSVStorage` | Supplies bounded UHRP identifiers, content values, and a transport-neutral content-provider boundary. | @@ -257,7 +258,7 @@ Use the modern protocol API for new applications when a replacement exists: | Bitcoin Signed Message | BRC-77 `SignedMessage` from `BSVMessage` | | Electrum and Bitcore ECIES | BRC-78 `EncryptedMessage` from `BSVMessage` | | BIP-32 protocol keys | BRC-42 derivation from `BSVKeys` | -| BIP-39 mnemonic backup or import | No replacement; use `BSVCompat` when required | +| BIP-39 mnemonic or BRC-157 backup | No replacement; use `BSVCompat` when required | ## Examples @@ -278,6 +279,34 @@ let account = try master.derived(path: "m/44'/236'/0'") print(account.neutered.serialized) ``` +### Entropy-rooted backups + +Create interchangeable BRC-157 mnemonic and BRC-140 share backups, then derive +the BRC-100 root at `m/0'/0'`: + +```swift +import BSVCompat + +let backup = try BRC157Entropy.generate() +let words = backup.mnemonic.phrase +let shares = try backup.backupShares(threshold: 2, shareCount: 3) +let rootKey = try backup.rootKey() + +let recovered = try BRC157Entropy.recover( + from: [shares[0], shares[2]], + entropyByteCount: backup.entropyByteCount +) +let recoveredRootKey = try recovered.rootKey() +precondition(recoveredRootKey == rootKey) +``` + +Persist trusted, authenticated `entropyByteCount` metadata with shares imported +from a 12-, 15-, 18-, or 21-word mnemonic so recovery can reproduce the exact +original words. A too-large supported length is intrinsically ambiguous and can +derive a different valid wallet, so wallet restore should also verify the +expected identity key. A nonempty BIP-39 passphrase is a separate secret and is +not recoverable from the mnemonic or shares. + ### BIP-276 Encode a Script payload as bounded BIP-276 text: diff --git a/Sources/BSVCompat/Mnemonic/BRC157Entropy.swift b/Sources/BSVCompat/Mnemonic/BRC157Entropy.swift new file mode 100644 index 0000000..4892808 --- /dev/null +++ b/Sources/BSVCompat/Mnemonic/BRC157Entropy.swift @@ -0,0 +1,171 @@ +import BSVKeys + +/// Stable validation failures specific to BRC-157 entropy handling. +public enum BRC157Error: Error, Equatable, Sendable { + /// BIP-39 entropy is exactly 16, 20, 24, 28, or 32 bytes. + case invalidEntropyByteCount(Int) + /// The entropy, after left-padding to 32 bytes, is not in secp256k1's `[1, n - 1]` range. + case invalidEntropyScalar + /// A recovered 32-byte entropy key has nonzero bytes outside the recorded original length. + case recoveredEntropyDoesNotFitByteCount(Int) + /// Hardened BIP-32 profile indices are restricted to 31 bits. + case invalidProfileIndex(UInt32) +} + +/// A validated BRC-157 entropy backup and its canonical BIP-39 representation. +/// +/// The original entropy length is retained so imported 12-, 15-, 18-, and 21-word mnemonics can +/// round-trip through a 32-byte BRC-140 entropy key without becoming a different 24-word mnemonic. +/// The entropy key is a backup subject only; it must never sign or serve directly as a wallet root +/// key. Operational profile keys use the hardened BIP-32 path `m/0'/i'`. +public struct BRC157Entropy: + Sendable, + CustomStringConvertible, + CustomDebugStringConvertible, + CustomReflectable +{ + /// BIP-39 entropy byte counts corresponding to 12, 15, 18, 21, and 24 words. + public static let supportedByteCounts: Set = [16, 20, 24, 28, 32] + + private static let scalarByteCount = 32 + private static let secp256k1Order: [UInt8] = [ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, + 0xba, 0xae, 0xdc, 0xe6, 0xaf, 0x48, 0xa0, 0x3b, + 0xbf, 0xd2, 0x5e, 0x8c, 0xd0, 0x36, 0x41, 0x41, + ] + + /// The exact unpadded BIP-39 entropy. This is secret material. + public let entropy: [UInt8] + + /// The canonical English BIP-39 mnemonic for ``entropy``. This is secret material. + public let mnemonic: Mnemonic + + /// The original entropy length that must accompany BRC-140 shares for exact word recovery. + public var entropyByteCount: Int { entropy.count } + + /// The entropy left-padded to exactly 32 bytes for BRC-140 sharing. + /// + /// This is the backup subject, not an operational signing key. + public var paddedEntropy: [UInt8] { + [UInt8](repeating: 0, count: Self.scalarByteCount - entropy.count) + entropy + } + + /// Creates and validates BRC-157 entropy at any BIP-39-supported word length. + public init(entropy: [UInt8]) throws { + guard Self.supportedByteCounts.contains(entropy.count) else { + throw BRC157Error.invalidEntropyByteCount(entropy.count) + } + let padded = [UInt8](repeating: 0, count: Self.scalarByteCount - entropy.count) + entropy + guard padded.contains(where: { $0 != 0 }), + padded.lexicographicallyPrecedes(Self.secp256k1Order) + else { + throw BRC157Error.invalidEntropyScalar + } + + self.entropy = entropy + mnemonic = try Mnemonic(entropy: entropy) + } + + /// Parses a strict English BIP-39 phrase, then applies BRC-157 scalar validation. + /// + /// `MnemonicError` is preserved for word-count, word-list, checksum, and phrase failures. + public init(mnemonicPhrase: String) throws { + try self.init(mnemonic: Mnemonic(mnemonicPhrase)) + } + + /// Applies BRC-157 scalar validation to an already validated mnemonic. + public init(mnemonic: Mnemonic) throws { + try self.init(entropy: mnemonic.entropy) + } + + /// Generates the required uniformly random 32-byte secp256k1 scalar for a new wallet. + public static func generate() throws -> Self { + try Self(entropy: PrivateKey.random().bytes) + } + + /// Restores the original entropy length from a recovered 32-byte BRC-140 entropy key. + /// + /// The caller must pass trusted, authenticated wallet or share-vault metadata. A length that + /// would discard nonzero high-order bytes is rejected. A larger supported length can absorb + /// genuine zero padding and is intrinsically ambiguous: it succeeds but produces a different + /// mnemonic and root. Wallet restore should therefore verify the expected identity key too. + public init(recoveredEntropyKey: PrivateKey, entropyByteCount: Int) throws { + guard Self.supportedByteCounts.contains(entropyByteCount) else { + throw BRC157Error.invalidEntropyByteCount(entropyByteCount) + } + let paddingCount = Self.scalarByteCount - entropyByteCount + guard recoveredEntropyKey.bytes.prefix(paddingCount).allSatisfy({ $0 == 0 }) else { + throw BRC157Error.recoveredEntropyDoesNotFitByteCount(entropyByteCount) + } + try self.init(entropy: Array(recoveredEntropyKey.bytes.suffix(entropyByteCount))) + } + + /// Splits the padded entropy key with the standard BRC-140 implementation. + public func backupShares(threshold: Int, shareCount: Int) throws -> [KeyShare] { + try KeySharing.split( + PrivateKey(paddedEntropy), + threshold: threshold, + shareCount: shareCount + ) + } + + /// Recovers entropy from BRC-140 shares using the trusted original entropy length. + /// + /// Higher-level restore code should authenticate that metadata and verify the derived identity + /// key against the expected wallet identity because an oversized supported length is ambiguous. + public static func recover( + from shares: [KeyShare], + entropyByteCount: Int + ) throws -> Self { + try Self( + recoveredEntropyKey: KeySharing.recover(shares), + entropyByteCount: entropyByteCount + ) + } + + /// Recovers entropy using BRC-157's last-resort leading-zero heuristic. + /// + /// Prefer ``recover(from:entropyByteCount:)``. This fallback can misidentify genuine entropy + /// beginning with four or more zero bytes, as noted by BRC-157. + public static func recoverUsingInferredByteCount(from shares: [KeyShare]) throws -> Self { + let recovered = try KeySharing.recover(shares) + return try Self( + recoveredEntropyKey: recovered, + entropyByteCount: inferredEntropyByteCount(from: recovered.bytes) + ) + } + + /// Derives profile `i` at the required hardened BIP-32 path `m/0'/i'`. + /// + /// Profile zero is the BRC-100 wallet root key. An empty BIP-39 passphrase is the interoperable + /// default; callers supporting a non-empty passphrase must back it up as a separate secret. + public func profileKey(index: UInt32, passphrase: String = "") throws -> PrivateKey { + guard index < HDChildNumber.hardenedOffset else { + throw BRC157Error.invalidProfileIndex(index) + } + let seed = try mnemonic.seed(passphrase: passphrase) + let master = try ExtendedPrivateKey(seed: seed.bytes, network: .mainnet) + let profiles = try master.derived(.hardened(0)) + return try profiles.derived(.hardened(index)).key + } + + /// Derives profile zero, the private counterparty to the BRC-100 identity key. + public func rootKey(passphrase: String = "") throws -> PrivateKey { + try profileKey(index: 0, passphrase: passphrase) + } + + /// A redacted description safe for interpolation and diagnostic logging. + public var description: String { "" } + + public var debugDescription: String { description } + + public var customMirror: Mirror { Mirror(reflecting: description) } + + private static func inferredEntropyByteCount(from paddedEntropy: [UInt8]) -> Int { + let leadingZeroCount = paddedEntropy.prefix(while: { $0 == 0 }).count + let significantByteCount = Self.scalarByteCount - leadingZeroCount + let rounded = ((significantByteCount + 3) / 4) * 4 + return min(Self.scalarByteCount, max(16, rounded)) + } +} diff --git a/Sources/BSVKeys/Backup/KeySharing.swift b/Sources/BSVKeys/Backup/KeySharing.swift index e4221b2..7f416a3 100644 --- a/Sources/BSVKeys/Backup/KeySharing.swift +++ b/Sources/BSVKeys/Backup/KeySharing.swift @@ -178,7 +178,7 @@ public enum KeySharing { private static let coordinateByteCount = 32 private static let seedByteCount = 64 - private static let maximumCoordinateAttempts = 16 + private static let maximumCoordinateAttempts = 5 private static let fieldPrimeBytes: [UInt8] = [ 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, @@ -292,7 +292,11 @@ public enum KeySharing { return shares } - /// Recovers a private key from a coherent set of canonical BRC-140 shares. + /// Recovers a private key from the first threshold canonical BRC-140 shares. + /// + /// Every supplied share must declare the same threshold and integrity tag. Later shares are + /// ignored after those set-wide checks, and duplicate coordinates are rejected among the + /// threshold-sized prefix actually used for interpolation. public static func recover(_ shares: [KeyShare]) throws -> PrivateKey { guard shares.count <= maximumShareCount else { throw KeyShareError.shareCountExceedsMaximum(shares.count) @@ -313,9 +317,10 @@ public enum KeySharing { ) } + let usedShares = shares.prefix(first.threshold) var xCoordinates: Set = [] - xCoordinates.reserveCapacity(shares.count) - for share in shares { + xCoordinates.reserveCapacity(first.threshold) + for share in usedShares { guard xCoordinates.insert(share.coordinateX).inserted else { throw KeyShareError.duplicateXCoordinate } @@ -323,24 +328,10 @@ public enum KeySharing { let prime = try fieldPrime() let budget = try arithmeticBudget() - let basis = shares.prefix(first.threshold).map { + let basis = usedShares.map { (x: $0.coordinateX, y: $0.coordinateY) } - // Do not silently ignore coherent-looking extras. Every extra point must - // lie on the polynomial defined by the threshold-sized basis. - for share in shares.dropFirst(first.threshold) { - let expected = try evaluate( - basis, - at: share.coordinateX, - prime: prime, - budget: budget - ) - guard expected == share.coordinateY else { - throw KeyShareError.inconsistentShare - } - } - let recovered = try evaluate( basis, at: .zero, diff --git a/Tests/BSVCompatTests/Mnemonic/BRC157EntropyTests.swift b/Tests/BSVCompatTests/Mnemonic/BRC157EntropyTests.swift new file mode 100644 index 0000000..5cc3656 --- /dev/null +++ b/Tests/BSVCompatTests/Mnemonic/BRC157EntropyTests.swift @@ -0,0 +1,260 @@ +import BSVCompat +import BSVKeys +import Foundation +import Testing + +@Suite("BRC-157 entropy backup") +struct BRC157EntropyTests { + private let workedPhrase = + "legal winner thank year wave sausage worth useful legal winner thank yellow" + + @Test("worked BRC-157 vector matches exactly") + func workedVector() throws { + let subject = try BRC157Entropy(mnemonicPhrase: workedPhrase) + + #expect(hex(subject.entropy) == "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f") + #expect( + hex(subject.paddedEntropy) + == "000000000000000000000000000000007f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f" + ) + #expect(subject.mnemonic.phrase == workedPhrase) + #expect( + hex(try subject.rootKey().bytes) + == "27e442c8015fc055789d6628f3b30461e8b2598aff74dc87ceef00dd8e670e55" + ) + } + + @Test("profiles match independently derived BIP-39 and BIP-32 vectors") + func profileVectors() throws { + let subject = try BRC157Entropy(mnemonicPhrase: workedPhrase) + let cases: [(UInt32, String)] = [ + (0, "27e442c8015fc055789d6628f3b30461e8b2598aff74dc87ceef00dd8e670e55"), + (1, "8d5bd9de4d42da1ee10fa7d09f14ba13512b4725844e9f936bdc35b9cb9e17dc"), + (2, "5874796bb9d0fc8fc4dfa18452d4eb5f2547970b6a31e9cd655b5b636cbb5d37"), + ( + 2_147_483_647, + "9ece45ac5d727eeb582b9fb52045034c4f6c6b97cddb11d79f94758172af45fe" + ), + ] + + for (index, expected) in cases { + #expect(hex(try subject.profileKey(index: index).bytes) == expected) + } + #expect(throws: BRC157Error.invalidProfileIndex(2_147_483_648)) { + try subject.profileKey(index: 2_147_483_648) + } + } + + @Test("every BIP-39 word count preserves entropy and its reference root") + func allBIP39WordCounts() throws { + let cases: [(bytes: Int, words: Int, phrase: String, root: String)] = [ + ( + 16, + 12, + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon actual", + "da1dbaa371674aad9b55a22a89f60b04c182db6ff44df2cb25f7a4547e98b667" + ), + ( + 20, + 15, + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon amateur", + "77d42e1062e7a5944c31da26a907a0da1cacdd4942a721c1dff70080a326368d" + ), + ( + 24, + 18, + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon author", + "0f3415c8818ddaa27dc6c90f30befa06322205b3fc799c70f6ceabdeb874f012" + ), + ( + 28, + 21, + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon breeze", + "0b593dc1498156ecf001b1c9c86bdd1aac86f5b6047131d4a6902737576391f9" + ), + ( + 32, + 24, + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon diesel", + "8049e464f17f071c20cbc3eb61d0aae4b3239b69df8d59524569b24e45b97229" + ), + ] + + for value in cases { + var entropy = [UInt8](repeating: 0, count: value.bytes) + entropy[value.bytes - 1] = 1 + let fromEntropy = try BRC157Entropy(entropy: entropy) + let fromWords = try BRC157Entropy(mnemonicPhrase: value.phrase) + + #expect(fromEntropy.mnemonic.phrase == value.phrase) + #expect(fromWords.entropy == entropy) + #expect(fromWords.mnemonic.words.count == value.words) + #expect(fromWords.entropyByteCount == value.bytes) + #expect(fromWords.paddedEntropy.count == 32) + #expect(hex(try fromWords.rootKey().bytes) == value.root) + } + } + + @Test("mnemonic validation errors remain typed") + func mnemonicValidationErrors() { + #expect(throws: MnemonicError.unknownWord("notaword")) { + try BRC157Entropy( + mnemonicPhrase: + "notaword abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + ) + } + #expect(throws: MnemonicError.checksumMismatch) { + try BRC157Entropy( + mnemonicPhrase: + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon" + ) + } + #expect(throws: MnemonicError.invalidWordCount(3)) { + try BRC157Entropy(mnemonicPhrase: "abandon abandon abandon") + } + } + + @Test("zero is rejected at every supported mnemonic length") + func zeroScalar() throws { + for byteCount in [16, 20, 24, 28, 32] { + let zero = [UInt8](repeating: 0, count: byteCount) + let validZeroMnemonic = try Mnemonic(entropy: zero) + + #expect(throws: BRC157Error.invalidEntropyScalar) { + try BRC157Entropy(entropy: zero) + } + #expect(throws: BRC157Error.invalidEntropyScalar) { + try BRC157Entropy(mnemonic: validZeroMnemonic) + } + } + } + + @Test("scalar group-order boundary is exact") + func scalarOrderBoundary() throws { + let order = bytes( + "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141" + ) + var orderMinusOne = order + orderMinusOne[31] -= 1 + let orderMnemonic = try Mnemonic(entropy: order) + + _ = try BRC157Entropy(entropy: orderMinusOne) + #expect(throws: BRC157Error.invalidEntropyScalar) { + try BRC157Entropy(entropy: order) + } + #expect(throws: BRC157Error.invalidEntropyScalar) { + try BRC157Entropy(mnemonic: orderMnemonic) + } + } + + @Test("entropy length validation is exact") + func entropyLengthValidation() { + for byteCount in [0, 1, 15, 17, 19, 21, 23, 25, 27, 29, 31, 33] { + #expect(throws: BRC157Error.invalidEntropyByteCount(byteCount)) { + try BRC157Entropy(entropy: [UInt8](repeating: 1, count: byteCount)) + } + } + } + + @Test("share recovery restores the recorded original words") + func shareRecovery() throws { + let original = try BRC157Entropy(mnemonicPhrase: workedPhrase) + let shares = try original.backupShares(threshold: 2, shareCount: 3) + let recovered = try BRC157Entropy.recover( + from: [shares[0], shares[2]], + entropyByteCount: 16 + ) + let inferred = try BRC157Entropy.recoverUsingInferredByteCount( + from: [shares[1], shares[2]] + ) + + #expect(recovered.entropy == original.entropy) + #expect(recovered.mnemonic.phrase == workedPhrase) + #expect(try recovered.rootKey() == original.rootKey()) + #expect(inferred.entropy == original.entropy) + #expect(inferred.mnemonic.phrase == workedPhrase) + + let wrongTwentyFourWords = try Mnemonic(entropy: original.paddedEntropy) + #expect( + wrongTwentyFourWords.phrase + == "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon " + + "abandon abstract wave sausage worth useful legal winner thank year wave sausage " + + "worth upgrade" + ) + #expect(wrongTwentyFourWords.phrase != recovered.mnemonic.phrase) + } + + @Test("recovery refuses to discard nonzero bytes for a wrong length") + func wrongRecoveryLength() throws { + let entropyKey = try PrivateKey([UInt8](repeating: 1, count: 32)) + + #expect(throws: BRC157Error.recoveredEntropyDoesNotFitByteCount(16)) { + try BRC157Entropy(recoveredEntropyKey: entropyKey, entropyByteCount: 16) + } + #expect(throws: BRC157Error.invalidEntropyByteCount(18)) { + try BRC157Entropy(recoveredEntropyKey: entropyKey, entropyByteCount: 18) + } + } + + @Test("oversized recovery length is intrinsically ambiguous") + func oversizedRecoveryLength() throws { + let original = try BRC157Entropy(mnemonicPhrase: workedPhrase) + let recovered = try BRC157Entropy( + recoveredEntropyKey: PrivateKey(original.paddedEntropy), + entropyByteCount: 20 + ) + + #expect(recovered.entropy.prefix(4).allSatisfy({ $0 == 0 })) + #expect(Array(recovered.entropy.suffix(16)) == original.entropy) + #expect(recovered.mnemonic.phrase != original.mnemonic.phrase) + #expect(try recovered.rootKey() != original.rootKey()) + } + + @Test("new wallet generation is always a valid 24-word scalar") + func generation() throws { + for _ in 0..<8 { + let generated = try BRC157Entropy.generate() + + #expect(generated.entropyByteCount == 32) + #expect(generated.mnemonic.words.count == 24) + #expect(generated.paddedEntropy == generated.entropy) + _ = try PrivateKey(generated.entropy) + } + } + + @Test("empty passphrase is the default and nonempty passphrases remain distinct") + func passphraseSemantics() throws { + let subject = try BRC157Entropy(mnemonicPhrase: workedPhrase) + + #expect(try subject.rootKey() == subject.rootKey(passphrase: "")) + #expect( + hex(try subject.rootKey(passphrase: "TREZOR").bytes) + == "4f318fbec35366ae7fdcb82a24a37d6b9bfc8df024abb390ff58f48ddcac9f15" + ) + #expect(try subject.rootKey() != subject.rootKey(passphrase: "TREZOR")) + } + + @Test("diagnostics never reveal entropy or words") + func diagnosticRedaction() throws { + let subject = try BRC157Entropy(mnemonicPhrase: workedPhrase) + var dumped = "" + dump(subject, to: &dumped) + + for diagnostic in [String(describing: subject), String(reflecting: subject), dumped] { + #expect(!diagnostic.contains("legal")) + #expect(!diagnostic.contains("7f7f")) + } + } + + private func hex(_ bytes: [UInt8]) -> String { + bytes.map { String(format: "%02x", $0) }.joined() + } + + private func bytes(_ hex: String) -> [UInt8] { + stride(from: 0, to: hex.count, by: 2).map { offset in + let start = hex.index(hex.startIndex, offsetBy: offset) + let end = hex.index(start, offsetBy: 2) + return UInt8(hex[start.. [UInt8] { - [UInt8](repeating: 0, count: count) + lock.lock() + counts.append(count) + lock.unlock() + return [UInt8](repeating: 0, count: count) + } + + var requestedCounts: [Int] { + lock.lock() + defer { lock.unlock() } + return counts } }