@pinkparrot/qsafe-sig 0.0.5 → 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -9
- package/benchmark.mjs +2 -2
- package/binary-writer-reader.mjs +1 -1
- package/constants.mjs +1 -2
- package/dist/qsafe-sig.browser.min.js +77 -50
- package/index.mjs +45 -53
- package/package.json +1 -1
- package/qsafeHelper.mjs +44 -7
- package/test.mjs +11 -19
package/README.md
CHANGED
|
@@ -31,15 +31,15 @@ const masterSeed = QsafeSigner.generateMasterKey(); // 32-byte Uint8Array
|
|
|
31
31
|
|
|
32
32
|
// Create a signer and derive a keypair from the seed
|
|
33
33
|
const signer = await QsafeSigner.create();
|
|
34
|
-
const {
|
|
34
|
+
const { hybridKey, secretKey } = signer.loadMasterKey(masterSeed);
|
|
35
35
|
|
|
36
36
|
// Sign
|
|
37
37
|
const message = new TextEncoder().encode('hello world');
|
|
38
|
-
const
|
|
38
|
+
const hybridSig = signer.sign(message);
|
|
39
39
|
|
|
40
40
|
// Verify (use createFull() for backward-compatible multi-version verification)
|
|
41
41
|
const verifier = await QsafeSigner.createFull();
|
|
42
|
-
const valid = await verifier.verify(message,
|
|
42
|
+
const valid = await verifier.verify(message, hybridSig, hybridKey);
|
|
43
43
|
console.log(valid); // true
|
|
44
44
|
```
|
|
45
45
|
|
|
@@ -48,7 +48,12 @@ console.log(valid); // true
|
|
|
48
48
|
Each `sign()` call produces a single `Uint8Array` containing:
|
|
49
49
|
|
|
50
50
|
```
|
|
51
|
-
[
|
|
51
|
+
[ ed25519 signature (64B) | MAYO signature (variant-dependent) ]
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Each `hybridKey` follow the format:
|
|
55
|
+
```
|
|
56
|
+
[ header (3B) | ed25519 pubKey | MAYO pubKey (variant-dependent) ]
|
|
52
57
|
```
|
|
53
58
|
|
|
54
59
|
The header encodes the protocol version and MAYO variant so `verify()` always knows what it's reading — no out-of-band metadata needed.
|
|
@@ -78,16 +83,16 @@ Generates a cryptographically random master seed.
|
|
|
78
83
|
|
|
79
84
|
- `size`: `16`, `24`, or `32` bytes. Default: `32`
|
|
80
85
|
|
|
81
|
-
### `
|
|
86
|
+
### `QsafeHelper.checkFormat(hybridKey, hybridSig?)` → `boolean`
|
|
82
87
|
|
|
83
|
-
Fast structural check: validates the header and byte length. **Does not perform any cryptographic verification
|
|
88
|
+
Fast structural check: validates the header and byte length. **Does not perform any cryptographic verification**.
|
|
84
89
|
|
|
85
|
-
### `signer.loadMasterKey(masterSeed)` → `{
|
|
90
|
+
### `signer.loadMasterKey(masterSeed)` → `{ hybridKey, secretKey }`
|
|
86
91
|
|
|
87
92
|
Derives and loads a keypair from the master seed. Must be called before `sign()`.
|
|
88
93
|
|
|
89
94
|
- `masterSeed`: `Uint8Array` of 16, 24, or 32 bytes
|
|
90
|
-
- Returns `{
|
|
95
|
+
- Returns `{ hybridKey: Uint8Array, secretKey: Uint8Array }`
|
|
91
96
|
|
|
92
97
|
The same instance can sign many messages after a single `loadMasterKey()` call.
|
|
93
98
|
|
|
@@ -95,7 +100,7 @@ The same instance can sign many messages after a single `loadMasterKey()` call.
|
|
|
95
100
|
|
|
96
101
|
Signs a message. Requires a prior `loadMasterKey()` call.
|
|
97
102
|
|
|
98
|
-
### `signer.verify(message,
|
|
103
|
+
### `signer.verify(message, hybridSig, hybridKey)` → `Promise<boolean>`
|
|
99
104
|
|
|
100
105
|
Verifies a hybrid signature. Lazy-loads the required WASM variant on first call, then caches it. Works across all registered protocol versions.
|
|
101
106
|
|
package/benchmark.mjs
CHANGED
|
@@ -11,7 +11,7 @@ async function chainVerify(variant) {
|
|
|
11
11
|
console.log(`\n-- Chain verify x${COUNT}: ${variant} --`);
|
|
12
12
|
|
|
13
13
|
const signer = await QsafeSigner.create(variant);
|
|
14
|
-
const {
|
|
14
|
+
const { hybridKey } = signer.loadMasterKey(SEED);
|
|
15
15
|
|
|
16
16
|
// -- Sign phase --
|
|
17
17
|
const messages = [];
|
|
@@ -30,7 +30,7 @@ async function chainVerify(variant) {
|
|
|
30
30
|
|
|
31
31
|
const t0verify = performance.now();
|
|
32
32
|
for (let i = 0; i < COUNT; i++)
|
|
33
|
-
if (!await verifier.verify(messages[i], sigs[i],
|
|
33
|
+
if (!await verifier.verify(messages[i], sigs[i], hybridKey)) failures++;
|
|
34
34
|
|
|
35
35
|
const verifyMs = performance.now() - t0verify;
|
|
36
36
|
console.log(`Verify : ${verifyMs.toFixed(2)} ms total | ~${(verifyMs / COUNT).toFixed(3)} ms/op`);
|
package/binary-writer-reader.mjs
CHANGED
package/constants.mjs
CHANGED
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
import { MayoSigner } from '@pinkparrot/qsafe-mayo-wasm';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* @typedef {{ sigSize: number, pubKeySize: number, seedSize: number }} VariantDesc
|
|
6
|
-
* @typedef {{ publicKey: Uint8Array, secretKey: Uint8Array }} Keypair */
|
|
5
|
+
* @typedef {{ sigSize: number, pubKeySize: number, seedSize: number }} VariantDesc */
|
|
7
6
|
|
|
8
7
|
/** Versioned protocol descriptors and loaders.
|
|
9
8
|
* - Each version entry describes the crypto params for that protocol version.
|
|
@@ -1778,7 +1778,17 @@ var VARIANT_ID = { "mayo1": 1, "mayo2": 2 };
|
|
|
1778
1778
|
var VARIANT_BY_ID = { 1: "mayo1", 2: "mayo2" };
|
|
1779
1779
|
|
|
1780
1780
|
// qsafeHelper.mjs
|
|
1781
|
-
var QsafeHelper = class {
|
|
1781
|
+
var QsafeHelper = class _QsafeHelper {
|
|
1782
|
+
/** Retrieves the descriptor for a given protocol version and variant, throwing if unknown.
|
|
1783
|
+
* @param {'mayo1' | 'mayo2'} [variant] defaults to DEFAULT_VARIANT
|
|
1784
|
+
* @param {string} [version] defaults to CURRENT_VERSION */
|
|
1785
|
+
static getVariantDescriptor(variant = DEFAULT_VARIANT, version = CURRENT_VERSION) {
|
|
1786
|
+
const vProto = PROTOCOL_VERSIONS[version];
|
|
1787
|
+
if (!vProto) throw new Error(`Unknown protocol version: ${version}`);
|
|
1788
|
+
const desc = vProto.variants[variant];
|
|
1789
|
+
if (!desc) throw new Error(`Unknown variant '${variant}' for protocol version ${version}`);
|
|
1790
|
+
return desc;
|
|
1791
|
+
}
|
|
1782
1792
|
/** Derives ed25519 + mayo seeds from a master seed via HKDF-SHA256.
|
|
1783
1793
|
* @param {Uint8Array} masterSeed @param {number} mayoSeedSize */
|
|
1784
1794
|
static deriveSeeds(masterSeed, mayoSeedSize) {
|
|
@@ -1786,10 +1796,23 @@ var QsafeHelper = class {
|
|
|
1786
1796
|
const mayoSeed = hkdf(sha256, masterSeed, void 0, HKDF_INFO_MAYO, mayoSeedSize);
|
|
1787
1797
|
return { edSeed, mayoSeed };
|
|
1788
1798
|
}
|
|
1789
|
-
/**
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1799
|
+
/** Build a QsafeSigner header for the given version and variant: <version(u16 BE) + variantId(u8)>
|
|
1800
|
+
* - Returned as a Uint8Array or write directly at cursor position if a BinaryWriter is provided.
|
|
1801
|
+
* @param {'mayo1' | 'mayo2'} [variant] defaults to DEFAULT_VARIANT
|
|
1802
|
+
* @param {string} [version] defaults to CURRENT_VERSION
|
|
1803
|
+
* @param {BinaryWriter} [writer] - Optional pre-allocated writer */
|
|
1804
|
+
static buildHeader(variant = DEFAULT_VARIANT, version = CURRENT_VERSION, writer) {
|
|
1805
|
+
const w = writer || new BinaryWriter(HEADER_SIZE);
|
|
1806
|
+
w.writeU16BE(Number(version));
|
|
1807
|
+
w.writeByte(VARIANT_ID[variant]);
|
|
1808
|
+
if (!writer) return w.getBytes();
|
|
1809
|
+
}
|
|
1810
|
+
/** Resolves version + variantId from a hybridKey header.
|
|
1811
|
+
* - Passing the hybridKey header (3 first bytes) will produce the same result.
|
|
1812
|
+
* @param {Uint8Array} hybridKey */
|
|
1813
|
+
static parseHeader(hybridKey) {
|
|
1814
|
+
if (hybridKey.length < HEADER_SIZE) return null;
|
|
1815
|
+
const reader = new BinaryReader(hybridKey);
|
|
1793
1816
|
const version = String(reader.readU16BE());
|
|
1794
1817
|
const variantId = reader.readByte();
|
|
1795
1818
|
const variant = VARIANT_BY_ID[variantId];
|
|
@@ -1797,6 +1820,16 @@ var QsafeHelper = class {
|
|
|
1797
1820
|
if (!vProto || !variant || !vProto.variants[variant]) return null;
|
|
1798
1821
|
return { version, variant, desc: vProto.variants[variant] };
|
|
1799
1822
|
}
|
|
1823
|
+
/** Quick format check for a hybridKey, without parsing the full signature.
|
|
1824
|
+
* @param {Uint8Array} hybridKey
|
|
1825
|
+
* @param {Uint8Array} [hybridSig] Optional: signature associated to the hybridKey. */
|
|
1826
|
+
static checkFormat(hybridKey, hybridSig) {
|
|
1827
|
+
const h = _QsafeHelper.parseHeader(hybridKey);
|
|
1828
|
+
if (!h) return false;
|
|
1829
|
+
if (hybridKey.length !== HEADER_SIZE + ED25519_PUB_SIZE + h.desc.pubKeySize) return false;
|
|
1830
|
+
if (hybridSig && hybridSig.length !== ED25519_SIG_SIZE + h.desc.sigSize) return false;
|
|
1831
|
+
return true;
|
|
1832
|
+
}
|
|
1800
1833
|
};
|
|
1801
1834
|
|
|
1802
1835
|
// node_modules/@noble/curves/utils.js
|
|
@@ -3001,34 +3034,6 @@ var QsafeSigner = class _QsafeSigner {
|
|
|
3001
3034
|
crypto.getRandomValues(seed);
|
|
3002
3035
|
return seed;
|
|
3003
3036
|
}
|
|
3004
|
-
/** Parses a signature header and resolves its protocol version and variant.
|
|
3005
|
-
* - Returns null if the header is invalid or references an unknown version/variant.
|
|
3006
|
-
* @param {Uint8Array} headerOrSignature */
|
|
3007
|
-
static parseHeader(headerOrSignature) {
|
|
3008
|
-
return QsafeHelper.parseHeader(headerOrSignature);
|
|
3009
|
-
}
|
|
3010
|
-
/** Verifies a hybrid signature. Lazy-loads the required WASM variant if not already cached.
|
|
3011
|
-
* - Works with any protocol version whose descriptors are registered above.
|
|
3012
|
-
* - Do not parallelize calls to verify(), async is justified by the lazy WASM loading. To parallelize, please use workers
|
|
3013
|
-
* @param {Uint8Array} message
|
|
3014
|
-
* @param {Uint8Array} signature - from sign()
|
|
3015
|
-
* @param {Uint8Array} publicKey - from loadMasterKey() */
|
|
3016
|
-
async verify(message, signature, publicKey) {
|
|
3017
|
-
const h = QsafeHelper.parseHeader(signature);
|
|
3018
|
-
if (!h) return false;
|
|
3019
|
-
if (signature.length !== HEADER_SIZE + ED25519_SIG_SIZE + h.desc.sigSize) return false;
|
|
3020
|
-
if (publicKey.length !== ED25519_PUB_SIZE + h.desc.pubKeySize) return false;
|
|
3021
|
-
const sigReader = new BinaryReader(signature);
|
|
3022
|
-
sigReader.read(HEADER_SIZE);
|
|
3023
|
-
const edSig = sigReader.read(ED25519_SIG_SIZE);
|
|
3024
|
-
const mayoSig = sigReader.read(h.desc.sigSize);
|
|
3025
|
-
const pubReader = new BinaryReader(publicKey);
|
|
3026
|
-
const edPub = pubReader.read(ED25519_PUB_SIZE);
|
|
3027
|
-
const mayoPub = pubReader.read(h.desc.pubKeySize);
|
|
3028
|
-
if (!ed25519.verify(edSig, message, edPub)) return false;
|
|
3029
|
-
const signer = await this.#ensureShared(h.version, h.variant);
|
|
3030
|
-
return signer.verify(message, mayoSig, mayoPub);
|
|
3031
|
-
}
|
|
3032
3037
|
/** Derives and loads a keypair from a master seed (16–32 bytes).
|
|
3033
3038
|
* - After this call, sign() is ready to use.
|
|
3034
3039
|
* - Requires a signer created with create(), not createFull().
|
|
@@ -3037,40 +3042,60 @@ var QsafeSigner = class _QsafeSigner {
|
|
|
3037
3042
|
const isValidSize = masterSeed instanceof Uint8Array && (masterSeed.length === 16 || masterSeed.length === 24 || masterSeed.length === 32);
|
|
3038
3043
|
if (!isValidSize) throw new TypeError("masterSeed must be a Uint8Array of 16, 24 or 32 bytes");
|
|
3039
3044
|
if (!this.#mayoSigner) throw new Error("No signing instance \u2014 use QsafeSigner.create(), not createFull()");
|
|
3040
|
-
const
|
|
3041
|
-
const desc = proto.variants[this.#variant];
|
|
3045
|
+
const desc = QsafeHelper.getVariantDescriptor(this.#variant, this.#version);
|
|
3042
3046
|
const { edSeed, mayoSeed } = QsafeHelper.deriveSeeds(masterSeed, desc.seedSize);
|
|
3043
3047
|
this.#edPriv = edSeed;
|
|
3044
3048
|
const mayo = this.#mayoSigner.keypairFromSeed(mayoSeed);
|
|
3045
3049
|
if (!mayo || !this.#mayoSigner.ready) throw new Error("MAYO keypair generation failed");
|
|
3046
|
-
const
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3050
|
+
const pubWriter = new BinaryWriter(HEADER_SIZE + ED25519_PUB_SIZE + desc.pubKeySize);
|
|
3051
|
+
QsafeHelper.buildHeader(this.#variant, this.#version, pubWriter);
|
|
3052
|
+
pubWriter.writeBytes(ed25519.getPublicKey(edSeed));
|
|
3053
|
+
pubWriter.writeBytes(mayo.publicKey);
|
|
3054
|
+
const secWriter = new BinaryWriter(1 + ED25519_PRIV_SIZE + desc.seedSize);
|
|
3055
|
+
secWriter.writeByte(VARIANT_ID[this.#variant]);
|
|
3056
|
+
secWriter.writeBytes(edSeed);
|
|
3057
|
+
secWriter.writeBytes(mayo.secretKey);
|
|
3058
|
+
return { hybridKey: pubWriter.getBytes(), secretKey: secWriter.getBytes() };
|
|
3054
3059
|
}
|
|
3055
3060
|
/** Signs a message. Requires a prior loadMasterKey() call.
|
|
3056
|
-
|
|
3057
|
-
|
|
3061
|
+
* - The same instance can sign many messages without re-loading the key.
|
|
3062
|
+
* - Returned hybrid signature: [ ed25519 signature (64B) | MAYO signature (variant-dependent) ]
|
|
3063
|
+
* @param {Uint8Array} message */
|
|
3058
3064
|
sign(message) {
|
|
3059
3065
|
if (!this.#edPriv) throw new Error("No key loaded \u2014 call loadMasterKey() first");
|
|
3060
3066
|
if (!this.#mayoSigner) throw new Error("No signing instance \u2014 use QsafeSigner.create(), not createFull()");
|
|
3061
3067
|
if (!this.#mayoSigner.ready) throw new Error("MAYO signer not ready \u2014 was create() called?");
|
|
3062
|
-
const
|
|
3063
|
-
const desc = proto.variants[this.#variant];
|
|
3068
|
+
const desc = QsafeHelper.getVariantDescriptor(this.#variant, this.#version);
|
|
3064
3069
|
const edSig = ed25519.sign(message, this.#edPriv);
|
|
3065
3070
|
const mayoSig = this.#mayoSigner.sign(message);
|
|
3066
3071
|
if (!mayoSig) throw new Error("MAYO sign() returned null");
|
|
3067
|
-
const writer = new BinaryWriter(
|
|
3068
|
-
writer.writeU16BE(Number(this.#version));
|
|
3069
|
-
writer.writeByte(VARIANT_ID[this.#variant]);
|
|
3072
|
+
const writer = new BinaryWriter(ED25519_SIG_SIZE + desc.sigSize);
|
|
3070
3073
|
writer.writeBytes(edSig);
|
|
3071
3074
|
writer.writeBytes(mayoSig);
|
|
3072
3075
|
return writer.getBytes();
|
|
3073
3076
|
}
|
|
3077
|
+
/** Verifies a hybrid signature. Lazy-loads the required WASM variant if not already cached.
|
|
3078
|
+
* - Works with any protocol version whose descriptors are registered above.
|
|
3079
|
+
* - Do not parallelize calls to verify(), async is justified by the lazy WASM loading. To parallelize, please use workers
|
|
3080
|
+
* @param {Uint8Array} message
|
|
3081
|
+
* @param {Uint8Array} hybridSig - from sign()
|
|
3082
|
+
* @param {Uint8Array} hybridKey - from loadMasterKey() */
|
|
3083
|
+
async verify(message, hybridSig, hybridKey) {
|
|
3084
|
+
const h = QsafeHelper.parseHeader(hybridKey);
|
|
3085
|
+
if (!h) return false;
|
|
3086
|
+
if (hybridSig.length !== ED25519_SIG_SIZE + h.desc.sigSize) return false;
|
|
3087
|
+
if (hybridKey.length !== HEADER_SIZE + ED25519_PUB_SIZE + h.desc.pubKeySize) return false;
|
|
3088
|
+
const sigReader = new BinaryReader(hybridSig);
|
|
3089
|
+
const edSig = sigReader.read(ED25519_SIG_SIZE);
|
|
3090
|
+
const mayoSig = sigReader.read(h.desc.sigSize);
|
|
3091
|
+
const pubReader = new BinaryReader(hybridKey);
|
|
3092
|
+
pubReader.read(HEADER_SIZE);
|
|
3093
|
+
const edPub = pubReader.read(ED25519_PUB_SIZE);
|
|
3094
|
+
const mayoPub = pubReader.read(h.desc.pubKeySize);
|
|
3095
|
+
if (!ed25519.verify(edSig, message, edPub)) return false;
|
|
3096
|
+
const signer = await this.#ensureShared(h.version, h.variant);
|
|
3097
|
+
return signer.verify(message, mayoSig, mayoPub);
|
|
3098
|
+
}
|
|
3074
3099
|
/** Loads and caches a shared MayoSigner for version+variant. Idempotent.
|
|
3075
3100
|
* These instances are ONLY used for verify() — keypairFromSeed() is never called on them.
|
|
3076
3101
|
* @param {string} version @param {'mayo1' | 'mayo2' | string} variant */
|
|
@@ -3086,7 +3111,9 @@ var QsafeSigner = class _QsafeSigner {
|
|
|
3086
3111
|
export {
|
|
3087
3112
|
AVAILABLE_VERSIONS,
|
|
3088
3113
|
CURRENT_VERSION,
|
|
3114
|
+
HEADER_SIZE,
|
|
3089
3115
|
PROTOCOL_VERSIONS,
|
|
3116
|
+
QsafeHelper,
|
|
3090
3117
|
QsafeSigner,
|
|
3091
3118
|
ed25519
|
|
3092
3119
|
};
|
package/index.mjs
CHANGED
|
@@ -6,10 +6,10 @@ import { PROTOCOL_VERSIONS, CURRENT_VERSION, DEFAULT_VARIANT, AVAILABLE_VERSIONS
|
|
|
6
6
|
ED25519_PRIV_SIZE, ED25519_PUB_SIZE, ED25519_SIG_SIZE,
|
|
7
7
|
HEADER_SIZE, VARIANT_ID } from './constants.mjs';
|
|
8
8
|
|
|
9
|
-
export { ed25519, PROTOCOL_VERSIONS, CURRENT_VERSION, AVAILABLE_VERSIONS };
|
|
9
|
+
export { ed25519, QsafeHelper, HEADER_SIZE, PROTOCOL_VERSIONS, CURRENT_VERSION, AVAILABLE_VERSIONS };
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* @typedef {{
|
|
12
|
+
* @typedef {{ hybridKey : Uint8Array, secretKey: Uint8Array }} Keypair
|
|
13
13
|
* @typedef {import('@pinkparrot/qsafe-mayo-wasm').MayoSigner} MayoSigner */
|
|
14
14
|
|
|
15
15
|
export class QsafeSigner {
|
|
@@ -67,40 +67,6 @@ export class QsafeSigner {
|
|
|
67
67
|
return seed;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
/** Parses a signature header and resolves its protocol version and variant.
|
|
71
|
-
* - Returns null if the header is invalid or references an unknown version/variant.
|
|
72
|
-
* @param {Uint8Array} headerOrSignature */
|
|
73
|
-
static parseHeader(headerOrSignature) { return QsafeHelper.parseHeader(headerOrSignature); }
|
|
74
|
-
|
|
75
|
-
/** Verifies a hybrid signature. Lazy-loads the required WASM variant if not already cached.
|
|
76
|
-
* - Works with any protocol version whose descriptors are registered above.
|
|
77
|
-
* - Do not parallelize calls to verify(), async is justified by the lazy WASM loading. To parallelize, please use workers
|
|
78
|
-
* @param {Uint8Array} message
|
|
79
|
-
* @param {Uint8Array} signature - from sign()
|
|
80
|
-
* @param {Uint8Array} publicKey - from loadMasterKey() */
|
|
81
|
-
async verify(message, signature, publicKey) {
|
|
82
|
-
const h = QsafeHelper.parseHeader(signature);
|
|
83
|
-
if (!h) return false; // invalid header or unknown version/variant
|
|
84
|
-
if (signature.length !== HEADER_SIZE + ED25519_SIG_SIZE + h.desc.sigSize) return false;
|
|
85
|
-
if (publicKey.length !== ED25519_PUB_SIZE + h.desc.pubKeySize) return false;
|
|
86
|
-
|
|
87
|
-
const sigReader = new BinaryReader(signature);
|
|
88
|
-
sigReader.read(HEADER_SIZE); // skip header already parsed
|
|
89
|
-
const edSig = sigReader.read(ED25519_SIG_SIZE);
|
|
90
|
-
const mayoSig = sigReader.read(h.desc.sigSize);
|
|
91
|
-
|
|
92
|
-
const pubReader = new BinaryReader(publicKey);
|
|
93
|
-
const edPub = pubReader.read(ED25519_PUB_SIZE);
|
|
94
|
-
const mayoPub = pubReader.read(h.desc.pubKeySize);
|
|
95
|
-
|
|
96
|
-
// Fast path: ed25519 first (pure JS, no WASM)
|
|
97
|
-
if (!ed25519.verify(edSig, message, edPub)) return false;
|
|
98
|
-
|
|
99
|
-
// Lazy-load the shared signer for this version+variant if not already cached
|
|
100
|
-
const signer = await this.#ensureShared(h.version, h.variant);
|
|
101
|
-
return signer.verify(message, mayoSig, mayoPub);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
70
|
/** Derives and loads a keypair from a master seed (16–32 bytes).
|
|
105
71
|
* - After this call, sign() is ready to use.
|
|
106
72
|
* - Requires a signer created with create(), not createFull().
|
|
@@ -110,52 +76,78 @@ export class QsafeSigner {
|
|
|
110
76
|
if (!isValidSize) throw new TypeError('masterSeed must be a Uint8Array of 16, 24 or 32 bytes');
|
|
111
77
|
if (!this.#mayoSigner) throw new Error('No signing instance — use QsafeSigner.create(), not createFull()');
|
|
112
78
|
|
|
113
|
-
|
|
114
|
-
const desc = proto.variants[this.#variant];
|
|
79
|
+
const desc = QsafeHelper.getVariantDescriptor(this.#variant, this.#version);
|
|
115
80
|
const { edSeed, mayoSeed } = QsafeHelper.deriveSeeds(masterSeed, desc.seedSize);
|
|
116
81
|
|
|
117
82
|
this.#edPriv = edSeed;
|
|
118
83
|
const mayo = this.#mayoSigner.keypairFromSeed(mayoSeed); // stores secretKey in this.#mayoSigner
|
|
119
84
|
if (!mayo || !this.#mayoSigner.ready) throw new Error('MAYO keypair generation failed');
|
|
120
85
|
|
|
121
|
-
//
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
86
|
+
// hybridKey = header + ed25519_pub(32) + mayo_pub
|
|
87
|
+
const pubWriter = new BinaryWriter(HEADER_SIZE + ED25519_PUB_SIZE + desc.pubKeySize);
|
|
88
|
+
QsafeHelper.buildHeader(this.#variant, this.#version, pubWriter);
|
|
89
|
+
pubWriter.writeBytes(ed25519.getPublicKey(edSeed));
|
|
90
|
+
pubWriter.writeBytes(mayo.publicKey);
|
|
125
91
|
|
|
126
92
|
// secretKey = variantId(1) + ed25519_priv(32) + mayo_sec(seedSize)
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
93
|
+
const secWriter = new BinaryWriter(1 + ED25519_PRIV_SIZE + desc.seedSize);
|
|
94
|
+
secWriter.writeByte(VARIANT_ID[this.#variant]);
|
|
95
|
+
secWriter.writeBytes(edSeed);
|
|
96
|
+
secWriter.writeBytes(mayo.secretKey);
|
|
131
97
|
|
|
132
|
-
return {
|
|
98
|
+
return { hybridKey: pubWriter.getBytes(), secretKey: secWriter.getBytes() };
|
|
133
99
|
}
|
|
134
100
|
|
|
135
101
|
/** Signs a message. Requires a prior loadMasterKey() call.
|
|
136
102
|
* - The same instance can sign many messages without re-loading the key.
|
|
103
|
+
* - Returned hybrid signature: [ ed25519 signature (64B) | MAYO signature (variant-dependent) ]
|
|
137
104
|
* @param {Uint8Array} message */
|
|
138
105
|
sign(message) {
|
|
139
106
|
if (!this.#edPriv) throw new Error('No key loaded — call loadMasterKey() first');
|
|
140
107
|
if (!this.#mayoSigner) throw new Error('No signing instance — use QsafeSigner.create(), not createFull()');
|
|
141
108
|
if (!this.#mayoSigner.ready) throw new Error('MAYO signer not ready — was create() called?');
|
|
142
109
|
|
|
143
|
-
|
|
144
|
-
const desc = proto.variants[this.#variant];
|
|
110
|
+
const desc = QsafeHelper.getVariantDescriptor(this.#variant, this.#version);
|
|
145
111
|
const edSig = ed25519.sign(message, this.#edPriv);
|
|
146
112
|
const mayoSig = this.#mayoSigner.sign(message);
|
|
147
113
|
if (!mayoSig) throw new Error('MAYO sign() returned null');
|
|
148
114
|
|
|
149
|
-
|
|
150
|
-
const writer = new BinaryWriter(HEADER_SIZE + ED25519_SIG_SIZE + desc.sigSize);
|
|
151
|
-
writer.writeU16BE(Number(this.#version));
|
|
152
|
-
writer.writeByte(VARIANT_ID[this.#variant]);
|
|
115
|
+
const writer = new BinaryWriter(ED25519_SIG_SIZE + desc.sigSize);
|
|
153
116
|
writer.writeBytes(edSig);
|
|
154
117
|
writer.writeBytes(mayoSig);
|
|
155
118
|
|
|
156
119
|
return writer.getBytes();
|
|
157
120
|
}
|
|
158
121
|
|
|
122
|
+
/** Verifies a hybrid signature. Lazy-loads the required WASM variant if not already cached.
|
|
123
|
+
* - Works with any protocol version whose descriptors are registered above.
|
|
124
|
+
* - Do not parallelize calls to verify(), async is justified by the lazy WASM loading. To parallelize, please use workers
|
|
125
|
+
* @param {Uint8Array} message
|
|
126
|
+
* @param {Uint8Array} hybridSig - from sign()
|
|
127
|
+
* @param {Uint8Array} hybridKey - from loadMasterKey() */
|
|
128
|
+
async verify(message, hybridSig, hybridKey) {
|
|
129
|
+
const h = QsafeHelper.parseHeader(hybridKey);
|
|
130
|
+
if (!h) return false; // invalid header or unknown version/variant
|
|
131
|
+
if (hybridSig.length !== ED25519_SIG_SIZE + h.desc.sigSize) return false;
|
|
132
|
+
if (hybridKey.length !== HEADER_SIZE + ED25519_PUB_SIZE + h.desc.pubKeySize) return false;
|
|
133
|
+
|
|
134
|
+
const sigReader = new BinaryReader(hybridSig);
|
|
135
|
+
const edSig = sigReader.read(ED25519_SIG_SIZE);
|
|
136
|
+
const mayoSig = sigReader.read(h.desc.sigSize);
|
|
137
|
+
|
|
138
|
+
const pubReader = new BinaryReader(hybridKey);
|
|
139
|
+
pubReader.read(HEADER_SIZE); // skip header already parsed
|
|
140
|
+
const edPub = pubReader.read(ED25519_PUB_SIZE);
|
|
141
|
+
const mayoPub = pubReader.read(h.desc.pubKeySize);
|
|
142
|
+
|
|
143
|
+
// Fast path: ed25519 first (pure JS, no WASM)
|
|
144
|
+
if (!ed25519.verify(edSig, message, edPub)) return false;
|
|
145
|
+
|
|
146
|
+
// Lazy-load the shared signer for this version+variant if not already cached
|
|
147
|
+
const signer = await this.#ensureShared(h.version, h.variant);
|
|
148
|
+
return signer.verify(message, mayoSig, mayoPub);
|
|
149
|
+
}
|
|
150
|
+
|
|
159
151
|
/** Loads and caches a shared MayoSigner for version+variant. Idempotent.
|
|
160
152
|
* These instances are ONLY used for verify() — keypairFromSeed() is never called on them.
|
|
161
153
|
* @param {string} version @param {'mayo1' | 'mayo2' | string} variant */
|
package/package.json
CHANGED
package/qsafeHelper.mjs
CHANGED
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import { hkdf } from '@noble/hashes/hkdf.js';
|
|
3
3
|
import { sha256 } from '@noble/hashes/sha2.js';
|
|
4
|
-
import { BinaryReader } from './binary-writer-reader.mjs';
|
|
5
|
-
import {
|
|
6
|
-
|
|
4
|
+
import { BinaryReader, BinaryWriter } from './binary-writer-reader.mjs';
|
|
5
|
+
import { HKDF_INFO_ED25519, HKDF_INFO_MAYO, DEFAULT_VARIANT, CURRENT_VERSION,
|
|
6
|
+
ED25519_SIG_SIZE, ED25519_PUB_SIZE, ED25519_PRIV_SIZE,
|
|
7
|
+
PROTOCOL_VERSIONS, HEADER_SIZE, VARIANT_BY_ID, VARIANT_ID } from './constants.mjs';
|
|
7
8
|
|
|
8
9
|
export class QsafeHelper {
|
|
10
|
+
/** Retrieves the descriptor for a given protocol version and variant, throwing if unknown.
|
|
11
|
+
* @param {'mayo1' | 'mayo2'} [variant] defaults to DEFAULT_VARIANT
|
|
12
|
+
* @param {string} [version] defaults to CURRENT_VERSION */
|
|
13
|
+
static getVariantDescriptor(variant = DEFAULT_VARIANT, version = CURRENT_VERSION) {
|
|
14
|
+
const vProto = PROTOCOL_VERSIONS[version];
|
|
15
|
+
if (!vProto) throw new Error(`Unknown protocol version: ${version}`);
|
|
16
|
+
const desc = vProto.variants[variant];
|
|
17
|
+
if (!desc) throw new Error(`Unknown variant '${variant}' for protocol version ${version}`);
|
|
18
|
+
return desc;
|
|
19
|
+
}
|
|
20
|
+
|
|
9
21
|
/** Derives ed25519 + mayo seeds from a master seed via HKDF-SHA256.
|
|
10
22
|
* @param {Uint8Array} masterSeed @param {number} mayoSeedSize */
|
|
11
23
|
static deriveSeeds(masterSeed, mayoSeedSize) {
|
|
@@ -14,10 +26,24 @@ export class QsafeHelper {
|
|
|
14
26
|
return { edSeed, mayoSeed };
|
|
15
27
|
}
|
|
16
28
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
29
|
+
/** Build a QsafeSigner header for the given version and variant: <version(u16 BE) + variantId(u8)>
|
|
30
|
+
* - Returned as a Uint8Array or write directly at cursor position if a BinaryWriter is provided.
|
|
31
|
+
* @param {'mayo1' | 'mayo2'} [variant] defaults to DEFAULT_VARIANT
|
|
32
|
+
* @param {string} [version] defaults to CURRENT_VERSION
|
|
33
|
+
* @param {BinaryWriter} [writer] - Optional pre-allocated writer */
|
|
34
|
+
static buildHeader(variant = DEFAULT_VARIANT, version = CURRENT_VERSION, writer) {
|
|
35
|
+
const w = writer || new BinaryWriter(HEADER_SIZE);
|
|
36
|
+
w.writeU16BE(Number(version));
|
|
37
|
+
w.writeByte(VARIANT_ID[variant]);
|
|
38
|
+
if (!writer) return w.getBytes();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Resolves version + variantId from a hybridKey header.
|
|
42
|
+
* - Passing the hybridKey header (3 first bytes) will produce the same result.
|
|
43
|
+
* @param {Uint8Array} hybridKey */
|
|
44
|
+
static parseHeader(hybridKey) {
|
|
45
|
+
if (hybridKey.length < HEADER_SIZE) return null;
|
|
46
|
+
const reader = new BinaryReader(hybridKey);
|
|
21
47
|
const version = String(reader.readU16BE());
|
|
22
48
|
const variantId = reader.readByte();
|
|
23
49
|
const variant = VARIANT_BY_ID[variantId];
|
|
@@ -25,4 +51,15 @@ export class QsafeHelper {
|
|
|
25
51
|
if (!vProto || !variant || !vProto.variants[variant]) return null;
|
|
26
52
|
return { version, variant, desc: vProto.variants[variant] };
|
|
27
53
|
}
|
|
54
|
+
|
|
55
|
+
/** Quick format check for a hybridKey, without parsing the full signature.
|
|
56
|
+
* @param {Uint8Array} hybridKey
|
|
57
|
+
* @param {Uint8Array} [hybridSig] Optional: signature associated to the hybridKey. */
|
|
58
|
+
static checkFormat(hybridKey, hybridSig) {
|
|
59
|
+
const h = QsafeHelper.parseHeader(hybridKey);
|
|
60
|
+
if (!h) return false; // invalid header or unknown version/variant
|
|
61
|
+
if (hybridKey.length !== HEADER_SIZE + ED25519_PUB_SIZE + h.desc.pubKeySize) return false;
|
|
62
|
+
if (hybridSig && hybridSig.length !== ED25519_SIG_SIZE + h.desc.sigSize) return false;
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
28
65
|
}
|
package/test.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
-
import { QsafeSigner } from './index.mjs';
|
|
2
|
+
import { QsafeHelper, QsafeSigner } from './index.mjs';
|
|
3
3
|
import { createRandomMessage, eq } from './test-helpers.mjs';
|
|
4
4
|
|
|
5
5
|
const NB_OF_TESTS = 100;
|
|
@@ -19,14 +19,14 @@ async function testVariant(variant, log = false) {
|
|
|
19
19
|
const kpA1 = signerA1.loadMasterKey(SEED_A);
|
|
20
20
|
const kpA2 = signerA2.loadMasterKey(SEED_A);
|
|
21
21
|
if (log) console.log(`- keypair generation time ~${((performance.now() - start) / 2).toFixed(2)} ms`);
|
|
22
|
-
console.assert(eq(kpA1.
|
|
22
|
+
console.assert(eq(kpA1.hybridKey, kpA2.hybridKey), `${variant} hybridKey should be deterministic`);
|
|
23
23
|
console.assert(eq(kpA1.secretKey, kpA2.secretKey), `${variant} secretKey should be deterministic`);
|
|
24
24
|
if (log) console.log(`✓ ${variant} keypair determinism OK`);
|
|
25
25
|
|
|
26
26
|
// -- Different seeds → different keypairs --
|
|
27
27
|
const signerB = await QsafeSigner.create(variant);
|
|
28
28
|
const kpB = signerB.loadMasterKey(SEED_B);
|
|
29
|
-
console.assert(!eq(kpA1.
|
|
29
|
+
console.assert(!eq(kpA1.hybridKey, kpB.hybridKey), `${variant} collision: same pubKey from different seeds`);
|
|
30
30
|
console.assert(!eq(kpA1.secretKey, kpB.secretKey), `${variant} collision: same secKey from different seeds`);
|
|
31
31
|
if (log) console.log(`✓ ${variant} seed isolation OK`);
|
|
32
32
|
|
|
@@ -38,38 +38,30 @@ async function testVariant(variant, log = false) {
|
|
|
38
38
|
|
|
39
39
|
start = performance.now();
|
|
40
40
|
const verifier = await QsafeSigner.createFull();
|
|
41
|
-
console.assert( await verifier.verify(msg1, sig1, kpA1.
|
|
42
|
-
console.assert( await verifier.verify(msg2, sig2, kpA1.
|
|
41
|
+
console.assert( await verifier.verify(msg1, sig1, kpA1.hybridKey), `${variant} sig1/msg1 rejected`);
|
|
42
|
+
console.assert( await verifier.verify(msg2, sig2, kpA1.hybridKey), `${variant} sig2/msg2 rejected`);
|
|
43
43
|
|
|
44
|
-
console.assert(!await verifier.verify(msg2, sig1, kpA1.
|
|
45
|
-
console.assert(!await verifier.verify(msg1, sig2, kpA1.
|
|
44
|
+
console.assert(!await verifier.verify(msg2, sig1, kpA1.hybridKey), `${variant} sig1 wrongly accepts msg2`);
|
|
45
|
+
console.assert(!await verifier.verify(msg1, sig2, kpA1.hybridKey), `${variant} sig2 wrongly accepts msg1`);
|
|
46
46
|
if (log) console.log(`✓ ${variant} verifying OK ~${((performance.now() - start) / 4).toFixed(2)} ms`);
|
|
47
47
|
|
|
48
48
|
// -- Wrong public key → rejected --
|
|
49
|
-
console.assert(!await verifier.verify(msg1, sig1, kpB.
|
|
49
|
+
console.assert(!await verifier.verify(msg1, sig1, kpB.hybridKey), `${variant} wrong pubkey wrongly accepted`);
|
|
50
50
|
if (log) console.log(`✓ ${variant} cross-key rejection OK`);
|
|
51
51
|
|
|
52
|
-
// -- Tampered signature → rejected by verify()
|
|
52
|
+
// -- Tampered signature → rejected by verify() --
|
|
53
53
|
const sigTampered = sig1.slice();
|
|
54
54
|
const tamperedIdx = 3 + Math.floor(Math.random() * (sig1.length - 3)); // random byte past the 3-byte header
|
|
55
55
|
sigTampered[tamperedIdx] ^= 0xFF;
|
|
56
|
-
console.assert(!await verifier.verify(msg1, sigTampered, kpA1.
|
|
56
|
+
console.assert(!await verifier.verify(msg1, sigTampered, kpA1.hybridKey), `${variant} tampered sig wrongly accepted (flipped byte at index ${tamperedIdx})`);
|
|
57
57
|
if (log) console.log(`✓ ${variant} tampered sig rejection OK (flipped byte at index ${tamperedIdx})`);
|
|
58
58
|
|
|
59
59
|
// -- Tampered message → rejected --
|
|
60
60
|
const msgTampered = msg1.slice();
|
|
61
61
|
const msgTamperedIdx = Math.floor(Math.random() * msg1.length);
|
|
62
62
|
msgTampered[msgTamperedIdx] ^= 0xFF;
|
|
63
|
-
console.assert(!await verifier.verify(msgTampered, sig1, kpA1.
|
|
63
|
+
console.assert(!await verifier.verify(msgTampered, sig1, kpA1.hybridKey), `${variant} tampered msg wrongly accepted (flipped byte at index ${msgTamperedIdx})`);
|
|
64
64
|
if (log) console.log(`✓ ${variant} tampered msg rejection OK (flipped byte at index ${msgTamperedIdx})`);
|
|
65
|
-
|
|
66
|
-
// -- checkFormat: structural check only (header + length), NOT a crypto check --
|
|
67
|
-
// A bit-flipped sig has the same length and a valid header → format is still "correct"
|
|
68
|
-
console.assert( QsafeSigner.checkFormat(sig1), `${variant} valid sig should pass checkFormat`);
|
|
69
|
-
console.assert( QsafeSigner.checkFormat(sigTampered), `${variant} tampered sig has valid format (use verify() for crypto)`);
|
|
70
|
-
console.assert(!QsafeSigner.checkFormat(sig1.slice(0, sig1.length - 1)), `${variant} truncated sig should fail checkFormat`);
|
|
71
|
-
console.assert(!QsafeSigner.checkFormat(new Uint8Array(3)), `${variant} garbage should fail checkFormat`);
|
|
72
|
-
if (log) console.log(`✓ ${variant} checkFormat OK`);
|
|
73
65
|
}
|
|
74
66
|
|
|
75
67
|
console.log(`-- Testing mayo1 --`);
|