Skip to content

Key Generation

sigil_watermark.keygen

Cryptographic key generation and PN sequence derivation for the Sigil watermark system.

Uses Ed25519 for author identity, HKDF for key derivation, and HMAC-based DRBG for deterministic pseudo-random noise sequences.

AuthorKeys dataclass

Ed25519 keypair for a watermark author.

Attributes:

Name Type Description
private_key bytes

32-byte raw Ed25519 private key seed.

public_key bytes

32-byte raw Ed25519 public key, used to derive all watermark parameters (ring positions, PN sequences, author ID).

Source code in src/sigil_watermark/keygen.py
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@dataclass
class AuthorKeys:
    """Ed25519 keypair for a watermark author.

    Attributes:
        private_key: 32-byte raw Ed25519 private key seed.
        public_key: 32-byte raw Ed25519 public key, used to derive all
            watermark parameters (ring positions, PN sequences, author ID).
    """

    private_key: bytes
    public_key: bytes

    @classmethod
    def from_private_key(cls, private_key_bytes: bytes) -> AuthorKeys:
        priv = Ed25519PrivateKey.from_private_bytes(private_key_bytes)
        pub = priv.public_key()
        pub_bytes = pub.public_bytes(
            serialization.Encoding.Raw,
            serialization.PublicFormat.Raw,
        )
        return cls(private_key=private_key_bytes, public_key=pub_bytes)

generate_author_keys(seed=None)

Generate a new Ed25519 keypair for watermark embedding.

Parameters:

Name Type Description Default
seed bytes | None

Optional seed bytes for deterministic key generation. If None, a cryptographically random key is generated. Useful for reproducible testing.

None

Returns:

Name Type Description
An AuthorKeys

class:AuthorKeys instance with both private and public keys.

Example

keys = generate_author_keys(seed=b"my-secret-seed") len(keys.public_key) 32

Source code in src/sigil_watermark/keygen.py
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
def generate_author_keys(seed: bytes | None = None) -> AuthorKeys:
    """Generate a new Ed25519 keypair for watermark embedding.

    Args:
        seed: Optional seed bytes for deterministic key generation.
            If ``None``, a cryptographically random key is generated.
            Useful for reproducible testing.

    Returns:
        An :class:`AuthorKeys` instance with both private and public keys.

    Example:
        >>> keys = generate_author_keys(seed=b"my-secret-seed")
        >>> len(keys.public_key)
        32
    """
    if seed is not None:
        # Use HKDF to derive a 32-byte key seed from arbitrary-length seed
        hkdf = HKDF(
            algorithm=hashes.SHA256(),
            length=32,
            salt=b"signarture-keygen-v1",
            info=b"ed25519-seed",
        )
        derived = hkdf.derive(seed)
        priv = Ed25519PrivateKey.from_private_bytes(derived)
    else:
        priv = Ed25519PrivateKey.generate()

    priv_bytes = priv.private_bytes(
        serialization.Encoding.Raw,
        serialization.PrivateFormat.Raw,
        serialization.NoEncryption(),
    )
    pub_bytes = priv.public_key().public_bytes(
        serialization.Encoding.Raw,
        serialization.PublicFormat.Raw,
    )
    return AuthorKeys(private_key=priv_bytes, public_key=pub_bytes)

derive_pn_sequence(public_key, length, config=DEFAULT_CONFIG)

Derive a deterministic bipolar (+1/-1) PN sequence from an author's public key.

Source code in src/sigil_watermark/keygen.py
120
121
122
123
124
125
126
127
def derive_pn_sequence(
    public_key: bytes,
    length: int,
    config: SigilConfig = DEFAULT_CONFIG,
) -> np.ndarray:
    """Derive a deterministic bipolar (+1/-1) PN sequence from an author's public key."""
    seed = _hkdf_derive(public_key, salt=config.pn_salt, info=b"pn-sequence", length=32)
    return _bytes_to_bipolar_pn(seed, length)

derive_ring_radii(public_key, config=DEFAULT_CONFIG)

Derive DFT ring radii from an author's public key.

Returns array of config.num_rings radii, each in [ring_radius_min, ring_radius_max], guaranteed to be distinct.

Source code in src/sigil_watermark/keygen.py
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
def derive_ring_radii(
    public_key: bytes,
    config: SigilConfig = DEFAULT_CONFIG,
) -> np.ndarray:
    """Derive DFT ring radii from an author's public key.

    Returns array of `config.num_rings` radii, each in [ring_radius_min, ring_radius_max],
    guaranteed to be distinct.
    """
    seed = _hkdf_derive(public_key, salt=config.ring_salt, info=b"ring-radii", length=32)
    rng = np.random.default_rng(seed=int.from_bytes(seed[:8], "big"))
    span = config.ring_radius_max - config.ring_radius_min

    # Generate evenly-spaced base radii with random jitter for distinctness
    base = np.linspace(0, 1, config.num_rings + 2)[1:-1]  # Exclude endpoints
    jitter = rng.uniform(-0.3 / config.num_rings, 0.3 / config.num_rings, config.num_rings)
    radii = np.clip(base + jitter, 0, 1) * span + config.ring_radius_min

    return np.sort(radii)

derive_ring_phase_offsets(public_key, num_rings, config=DEFAULT_CONFIG)

Derive key-dependent phase offsets for ring frequencies.

Returns array of phase offsets in [0, 2*pi) for each ring.

Source code in src/sigil_watermark/keygen.py
151
152
153
154
155
156
157
158
159
160
161
162
def derive_ring_phase_offsets(
    public_key: bytes,
    num_rings: int,
    config: SigilConfig = DEFAULT_CONFIG,
) -> np.ndarray:
    """Derive key-dependent phase offsets for ring frequencies.

    Returns array of phase offsets in [0, 2*pi) for each ring.
    """
    seed = _hkdf_derive(public_key, salt=config.ring_salt, info=b"ring-phase-offsets", length=32)
    rng = np.random.default_rng(seed=int.from_bytes(seed[:8], "big"))
    return rng.uniform(0, 2 * np.pi, num_rings)

derive_content_ring_radii(public_key, image, config=DEFAULT_CONFIG)

Derive content-dependent ring radii from image hash + author key.

The image content is hashed (low-frequency DCT to be robust to minor changes) and combined with the key to produce ring positions that an attacker cannot predict without the original image.

Returns array of config.num_content_rings radii.

Source code in src/sigil_watermark/keygen.py
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
def derive_content_ring_radii(
    public_key: bytes,
    image: np.ndarray,
    config: SigilConfig = DEFAULT_CONFIG,
) -> np.ndarray:
    """Derive content-dependent ring radii from image hash + author key.

    The image content is hashed (low-frequency DCT to be robust to minor
    changes) and combined with the key to produce ring positions that an
    attacker cannot predict without the original image.

    Returns array of `config.num_content_rings` radii.
    """
    # Use a coarse image hash: downsample to 8x8, take mean of blocks
    h, w = image.shape[:2]
    if image.ndim == 3:
        gray = 0.299 * image[:, :, 0] + 0.587 * image[:, :, 1] + 0.114 * image[:, :, 2]
    else:
        gray = image
    block_h, block_w = h // 8, w // 8
    coarse = np.zeros(64, dtype=np.float64)
    for i in range(8):
        for j in range(8):
            block = gray[i * block_h : (i + 1) * block_h, j * block_w : (j + 1) * block_w]
            coarse[i * 8 + j] = block.mean()
    # Quantize to bytes for hashing (robust to small pixel changes)
    coarse_bytes = np.clip(coarse, 0, 255).astype(np.uint8).tobytes()

    # Combine image hash with key
    content_hash = hashlib.sha256(public_key + coarse_bytes).digest()
    seed = _hkdf_derive(
        content_hash, salt=config.content_ring_salt, info=b"content-ring-radii", length=32
    )
    rng = np.random.default_rng(seed=int.from_bytes(seed[:8], "big"))

    span = config.ring_radius_max - config.ring_radius_min
    radii = rng.uniform(0, 1, config.num_content_rings) * span + config.ring_radius_min
    return np.sort(radii)

derive_sentinel_ring_radii(config=DEFAULT_CONFIG)

Derive sentinel ring radii from server secret.

Sentinel rings are at fixed positions known only to the server. If key-derived rings are removed but sentinels remain, tampering is suspected.

Returns array of config.num_sentinel_rings radii.

Source code in src/sigil_watermark/keygen.py
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
def derive_sentinel_ring_radii(
    config: SigilConfig = DEFAULT_CONFIG,
) -> np.ndarray:
    """Derive sentinel ring radii from server secret.

    Sentinel rings are at fixed positions known only to the server.
    If key-derived rings are removed but sentinels remain, tampering
    is suspected.

    Returns array of `config.num_sentinel_rings` radii.
    """
    seed = _hkdf_derive(
        config.sentinel_secret, salt=config.sentinel_salt, info=b"sentinel-ring-radii", length=32
    )
    rng = np.random.default_rng(seed=int.from_bytes(seed[:8], "big"))

    span = config.ring_radius_max - config.ring_radius_min
    radii = rng.uniform(0, 1, config.num_sentinel_rings) * span + config.ring_radius_min
    return np.sort(radii)

derive_author_id(public_key, config=DEFAULT_CONFIG)

Derive a truncated author ID (list of bits) from the public key.

Source code in src/sigil_watermark/keygen.py
226
227
228
229
230
231
232
233
def derive_author_id(
    public_key: bytes,
    config: SigilConfig = DEFAULT_CONFIG,
) -> list[int]:
    """Derive a truncated author ID (list of bits) from the public key."""
    h = hashlib.sha256(b"signarture-author-id-v1" + public_key).digest()
    bits = np.unpackbits(np.frombuffer(h, dtype=np.uint8))
    return bits[: config.author_id_bits].tolist()

derive_author_index(public_key, config=DEFAULT_CONFIG)

Derive the 20-bit author index for tier-2 blind scanning.

Source code in src/sigil_watermark/keygen.py
236
237
238
239
240
241
242
243
def derive_author_index(
    public_key: bytes,
    config: SigilConfig = DEFAULT_CONFIG,
) -> list[int]:
    """Derive the 20-bit author index for tier-2 blind scanning."""
    h = hashlib.sha256(b"signarture-author-index-v1" + public_key).digest()
    bits = np.unpackbits(np.frombuffer(h, dtype=np.uint8))
    return bits[: config.author_index_bits].tolist()

derive_ghost_hash(public_key, config=DEFAULT_CONFIG)

Derive ghost hash bits from public key for ghost-layer author binning.

Returns a list of ghost_hash_bits bits derived deterministically from the public key. Used to narrow O(N) author search to O(1) via indexed lookup in the database.

Source code in src/sigil_watermark/keygen.py
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
def derive_ghost_hash(
    public_key: bytes,
    config: SigilConfig = DEFAULT_CONFIG,
) -> list[int]:
    """Derive ghost hash bits from public key for ghost-layer author binning.

    Returns a list of ghost_hash_bits bits derived deterministically from
    the public key. Used to narrow O(N) author search to O(1) via indexed
    lookup in the database.
    """
    h = hashlib.sha256(b"signarture-ghost-hash-v1" + public_key).digest()
    bits = []
    for i in range(config.ghost_hash_bits):
        byte_idx = i // 8
        bit_idx = i % 8
        bits.append((h[byte_idx] >> bit_idx) & 1)
    return bits

get_ghost_hash_pns(num_bits, length, config=DEFAULT_CONFIG)

Generate universal PN sequences for ghost hash bit encoding.

Each PN is derived from a unique universal seed, independent of any author key. During embedding, each PN is added with a sign (+/-1) determined by the author's ghost hash bit. During detection, correlating with each PN recovers the sign = the hash bit.

Source code in src/sigil_watermark/keygen.py
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
def get_ghost_hash_pns(
    num_bits: int,
    length: int,
    config: SigilConfig = DEFAULT_CONFIG,
) -> list[np.ndarray]:
    """Generate universal PN sequences for ghost hash bit encoding.

    Each PN is derived from a unique universal seed, independent of any
    author key. During embedding, each PN is added with a sign (+/-1)
    determined by the author's ghost hash bit. During detection, correlating
    with each PN recovers the sign = the hash bit.
    """
    pns = []
    for i in range(num_bits):
        seed_bytes = hashlib.sha256(f"signarture-ghost-hash-pn-v1-{i}".encode()).digest()
        pn = _bytes_to_bipolar_pn(seed_bytes, length)
        pns.append(pn)
    return pns

build_ghost_composite_pn(public_key, length, config=DEFAULT_CONFIG)

Build the composite ghost PN for an author.

Encodes the author's ghost hash bits into a composite PN sequence by summing sign-modulated universal hash PNs. The composite has unit variance regardless of the number of hash bits.

Source code in src/sigil_watermark/keygen.py
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
def build_ghost_composite_pn(
    public_key: bytes,
    length: int,
    config: SigilConfig = DEFAULT_CONFIG,
) -> np.ndarray:
    """Build the composite ghost PN for an author.

    Encodes the author's ghost hash bits into a composite PN sequence
    by summing sign-modulated universal hash PNs. The composite has
    unit variance regardless of the number of hash bits.
    """
    ghost_hash = derive_ghost_hash(public_key, config)
    hash_pns = get_ghost_hash_pns(config.ghost_hash_bits, length, config)

    composite = np.zeros(length)
    for i, bit in enumerate(ghost_hash):
        sign = 1.0 if bit == 1 else -1.0
        composite += sign * hash_pns[i]
    composite /= np.sqrt(len(ghost_hash))
    return composite

get_universal_beacon_pn(length, config=DEFAULT_CONFIG)

Get the universal Signarture beacon PN sequence (same for all watermarks).

Source code in src/sigil_watermark/keygen.py
307
308
309
310
311
312
313
def get_universal_beacon_pn(
    length: int,
    config: SigilConfig = DEFAULT_CONFIG,
) -> np.ndarray:
    """Get the universal Signarture beacon PN sequence (same for all watermarks)."""
    seed = _hkdf_derive(config.beacon_seed, salt=config.beacon_salt, info=b"beacon-pn", length=32)
    return _bytes_to_bipolar_pn(seed, length)

get_universal_index_pn(length, config=DEFAULT_CONFIG)

Get the universal PN sequence used for author index embedding (tier 2).

Source code in src/sigil_watermark/keygen.py
316
317
318
319
320
321
322
323
324
def get_universal_index_pn(
    length: int,
    config: SigilConfig = DEFAULT_CONFIG,
) -> np.ndarray:
    """Get the universal PN sequence used for author index embedding (tier 2)."""
    seed = _hkdf_derive(
        config.universal_pn_seed, salt=config.beacon_salt, info=b"index-pn", length=32
    )
    return _bytes_to_bipolar_pn(seed, length)