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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ const wallet = newWalletFromExtendedSeed('0x01000000...'); // 51-byte hex
| Method | Returns | Description |
|--------|---------|-------------|
| `getAddressStr()` | `string` | Address with Q prefix (e.g., `Qabc123...`) |
| `getAddress()` | `Uint8Array` | Raw 20-byte address |
| `getAddress()` | `Uint8Array` | Raw 48-byte address |
| `getMnemonic()` | `string` | 34-word mnemonic phrase |
| `getPK()` | `Uint8Array` | Public key (2,592 bytes) |
| `getSK()` | `Uint8Array` | Secret key (4,896 bytes) |
Expand All @@ -116,7 +116,7 @@ const wallet = newWalletFromExtendedSeed('0x01000000...'); // 51-byte hex

### Address Utilities

**Address Format:** `Q` prefix + 40 lowercase hex characters (41 chars total).
**Address Format:** `Q` prefix + 96 lowercase hex characters (97 chars total).
- Output is always lowercase; input parsing is case-insensitive
- No checksum encoding (unlike EIP-55) — `isValidAddress()` checks format only, not correctness. A single mistyped character will produce a valid but unrelated address. Applications should implement their own checksum or confirmation UX to guard against transcription errors. See [SECURITY.md](SECURITY.md#address-security) for recommendations.

Expand Down
12 changes: 7 additions & 5 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,12 @@ Seed (48 bytes, random)
### Address Derivation

```
Address = SHAKE-256(Descriptor || PublicKey, 20 bytes)
Address = SHAKE-256(Descriptor || PublicKey, 48 bytes)
```

Addresses are 20 bytes, displayed with a `Q` prefix in hexadecimal (41 characters total).
Addresses are 48 bytes (384 bits), displayed with a `Q` prefix in hexadecimal (97 characters total).

The 48-byte (384-bit) address provides **NIST Category 5** post-quantum collision resistance. A shorter address would reduce collision resistance below the security level of the underlying signature schemes (ML-DSA-87 and SPHINCS+-256s both target NIST Level 5). The 48-byte size ensures the address does not become the weakest link in the security chain.

---

Expand Down Expand Up @@ -88,10 +90,10 @@ Addresses are 20 bytes, displayed with a `Q` prefix in hexadecimal (41 character

### No Built-in Checksum

**Important:** QRL addresses do not include a checksum (unlike EIP-55 mixed-case encoding in Ethereum). `isValidAddress()` only checks the structural format — `Q` prefix followed by 40 hex characters — it cannot detect a mistyped or truncated address.
**Important:** QRL addresses do not include a checksum (unlike EIP-55 mixed-case encoding in Ethereum). `isValidAddress()` only checks the structural format — `Q` prefix followed by 96 hex characters — it cannot detect a mistyped or truncated address.

**Implications:**
- Any 20-byte hex value with a `Q` prefix passes validation
- Any 48-byte hex value with a `Q` prefix passes validation
- A single character error produces a valid but unrelated address
- Funds sent to a mistyped address are unrecoverable

Expand Down Expand Up @@ -170,7 +172,7 @@ This is by design for FIPS 204 compliance and go-qrllib cross-implementation com
| `new Descriptor(bytes)` | Exactly 3 bytes, valid wallet type |
| `wallet.sign(message)` | message is Uint8Array |
| `MLDSA87.verify(sig, msg, pk)` | All inputs are Uint8Array of correct lengths |
| `stringToAddress(str)` | Starts with Q, 40 hex characters |
| `stringToAddress(str)` | Starts with Q, 96 hex characters |

### Error Handling

Expand Down
68 changes: 36 additions & 32 deletions dist/cjs/wallet.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
const DESCRIPTOR_SIZE = 3;

/** @type {number} Address length in bytes */
const ADDRESS_SIZE = 20;
const ADDRESS_SIZE = 48;

/** @type {number} Seed length in bytes */
const SEED_SIZE = 48;
Expand Down Expand Up @@ -1978,8 +1978,8 @@ function cryptoSignVerify(sig, m, pk, ctx) {
* @module wallet/common/address
*
* Address Format:
* - String form: "Q" prefix followed by 40 lowercase hex characters (41 chars total)
* - Byte form: 20-byte SHAKE-256 hash of (descriptor || public key)
* - String form: "Q" prefix followed by 96 lowercase hex characters (97 chars total)
* - Byte form: 48-byte SHAKE-256 hash of (descriptor || public key)
* - Output is always lowercase hex; input parsing is case-insensitive for both
* the "Q"/"q" prefix and hex characters
* - Unlike EIP-55, no checksum encoding is used in the address itself
Expand All @@ -2002,8 +2002,8 @@ function addressToString(addrBytes) {

/**
* Convert address string to bytes.
* @param {string} addrStr - Address string starting with 'Q' followed by 40 hex characters.
* @returns {Uint8Array} 20-byte address.
* @param {string} addrStr - Address string starting with 'Q' followed by 96 hex characters.
* @returns {Uint8Array} 48-byte address.
* @throws {Error} If address format is invalid.
*/
function stringToAddress(addrStr) {
Expand All @@ -2030,7 +2030,7 @@ function stringToAddress(addrStr) {

/**
* Check if a string is a valid QRL address format (structure only).
* QRL addresses contain no checksum — any well-formed Q + 40 hex string passes.
* QRL addresses contain no checksum — any well-formed Q + 96 hex string passes.
* Applications should add their own confirmation or checksum layer.
* @param {string} addrStr - Address string to validate.
* @returns {boolean} True if valid address format.
Expand All @@ -2048,7 +2048,7 @@ function isValidAddress(addrStr) {
* Derive an address from a public key and descriptor.
* @param {Uint8Array} pk
* @param {Descriptor} descriptor
* @returns {Uint8Array} 20-byte address.
* @returns {Uint8Array} 48-byte address.
* @throws {Error} If pk length mismatch.
*/
function getAddressFromPKAndDescriptor(pk, descriptor) {
Expand Down Expand Up @@ -2342,7 +2342,7 @@ function isUint8(input) {
function isHexLike(input) {
if (typeof input !== 'string') return false;
const s = input.trim().replace(/^0x/i, '');
return /^[0-9a-fA-F\s:_-]*$/.test(s);
return /[0-9a-fA-F]/.test(s) && /^[0-9a-fA-F\s:_-]+$/.test(s);
}

/**
Expand Down Expand Up @@ -2524,17 +2524,15 @@ class ExtendedSeed {
/**
* Layout: [3 bytes descriptor] || [48 bytes seed].
* @param {Uint8Array} bytes Exactly 51 bytes.
* @param {{ skipValidation?: boolean }} [options]
* @throws {Error} If size mismatch.
* @throws {Error} If size mismatch or invalid wallet type.
*/
constructor(bytes, options = {}) {
constructor(bytes) {
if (!bytes || bytes.length !== EXTENDED_SEED_SIZE) {
throw new Error(`ExtendedSeed must be ${EXTENDED_SEED_SIZE} bytes`);
}
const { skipValidation = false } = options;
/** @private @type {Uint8Array} */
this.bytes = Uint8Array.from(bytes);
if (!skipValidation && !isValidWalletType(this.bytes[0])) {
if (!isValidWalletType(this.bytes[0])) {
throw new Error('Invalid wallet type in descriptor');
}
}
Expand Down Expand Up @@ -2607,17 +2605,6 @@ class ExtendedSeed {
zeroize() {
this.bytes.fill(0);
}

/**
* Internal helper: construct without wallet type validation.
* @param {string|Uint8Array|Buffer|number[]} input
* @returns {ExtendedSeed}
*/
static fromUnchecked(input) {
return new ExtendedSeed(toFixedU8(input, EXTENDED_SEED_SIZE, 'ExtendedSeed'), {
skipValidation: true,
});
}
}

/**
Expand Down Expand Up @@ -2672,7 +2659,7 @@ function randomBytes(size) {
}
{
let acc = 0;
for (let i = 0; i < 16; i++) acc |= out[i];
for (let i = 0; i < size; i++) acc |= out[i];
if (acc === 0) throw new Error('getRandomValues returned all zeros');
}
return out;
Expand Down Expand Up @@ -6977,6 +6964,8 @@ class Wallet {
this.pk = pk;
this.sk = sk;
this.extendedSeed = ExtendedSeed.newExtendedSeed(descriptor, seed);
/** @private */
this._zeroized = false;
}

/**
Expand Down Expand Up @@ -7047,28 +7036,37 @@ class Wallet {
return new Descriptor(this.descriptor.toBytes());
}

/**
* @private
* @throws {Error} If the wallet has been zeroized.
*/
_requireLive() {
if (this._zeroized) {
throw new Error('Wallet has been zeroized');
}
}

/** @returns {ExtendedSeed} */
getExtendedSeed() {
const bytes = this.extendedSeed.toBytes();
try {
return ExtendedSeed.from(bytes);
} catch {
return ExtendedSeed.fromUnchecked(bytes);
}
this._requireLive();
return ExtendedSeed.from(this.extendedSeed.toBytes());
}

/** @returns {Seed} */
getSeed() {
this._requireLive();
return new Seed(this.seed.toBytes());
}

/** @returns {string} hex(ExtendedSeed) */
getHexExtendedSeed() {
return `0x${bytesToHex(this.extendedSeed.toBytes())}`;
this._requireLive();
return `0x${bytesToHex(this.getExtendedSeed().toBytes())}`;
}

/** @returns {string} */
getMnemonic() {
this._requireLive();
return binToMnemonic(this.getExtendedSeed().toBytes());
}

Expand All @@ -7085,6 +7083,7 @@ class Wallet {
* returned by this method.
*/
getSK() {
this._requireLive();
return this.sk.slice();
}

Expand All @@ -7094,6 +7093,7 @@ class Wallet {
* @returns {Uint8Array} Signature bytes.
*/
sign(message) {
this._requireLive();
return sign(this.sk, message);
}

Expand All @@ -7119,13 +7119,17 @@ class Wallet {
zeroize() {
if (this.sk) {
this.sk.fill(0);
this.sk = null;
}
if (this.seed) {
this.seed.zeroize();
this.seed = null;
}
if (this.extendedSeed) {
this.extendedSeed.zeroize();
this.extendedSeed = null;
}
this._zeroized = true;
}
}

Expand Down
Loading
Loading