355 lines
8.9 KiB
Markdown
355 lines
8.9 KiB
Markdown
# RFC-007: LTHN Key Derivation
|
|
|
|
**Status**: Draft
|
|
**Author**: [Snider](https://github.com/Snider/)
|
|
**Created**: 2026-01-13
|
|
**License**: EUPL-1.2
|
|
**Depends On**: RFC-002
|
|
|
|
---
|
|
|
|
## Abstract
|
|
|
|
LTHN (Leet-Hash-Nonce) is a rainbow-table resistant key derivation function used for streaming DRM with time-limited access. It generates rolling keys that automatically expire without requiring revocation infrastructure.
|
|
|
|
## 1. Overview
|
|
|
|
LTHN provides:
|
|
- Rainbow-table resistant hashing
|
|
- Time-based key rolling
|
|
- Zero-trust key derivation (no key server)
|
|
- Configurable cadence (daily to hourly)
|
|
|
|
## 2. Motivation
|
|
|
|
Traditional DRM requires:
|
|
- Central key server
|
|
- License validation
|
|
- Revocation lists
|
|
- Network connectivity
|
|
|
|
LTHN eliminates these by:
|
|
- Deriving keys from public information + secret
|
|
- Time-bounding keys automatically
|
|
- Making rainbow tables impractical
|
|
- Working completely offline
|
|
|
|
## 3. Algorithm
|
|
|
|
### 3.1 Core Function
|
|
|
|
The LTHN hash is implemented in the Enchantrix library:
|
|
|
|
```go
|
|
import "github.com/Snider/Enchantrix/pkg/crypt"
|
|
|
|
cryptService := crypt.NewService()
|
|
lthnHash := cryptService.Hash(crypt.LTHN, input)
|
|
```
|
|
|
|
**LTHN formula**:
|
|
```
|
|
LTHN(input) = SHA256(input || reverse_leet(input))
|
|
```
|
|
|
|
Where `reverse_leet` performs bidirectional character substitution.
|
|
|
|
### 3.2 Reverse Leet Mapping
|
|
|
|
| Original | Leet | Bidirectional |
|
|
|----------|------|---------------|
|
|
| o | 0 | o ↔ 0 |
|
|
| l | 1 | l ↔ 1 |
|
|
| e | 3 | e ↔ 3 |
|
|
| a | 4 | a ↔ 4 |
|
|
| s | z | s ↔ z |
|
|
| t | 7 | t ↔ 7 |
|
|
|
|
### 3.3 Example
|
|
|
|
```
|
|
Input: "2026-01-13:license:fp"
|
|
reverse_leet: "pf:3zn3ci1:31-10-6202"
|
|
Combined: "2026-01-13:license:fppf:3zn3ci1:31-10-6202"
|
|
Result: SHA256(combined) → 32-byte hash
|
|
```
|
|
|
|
## 4. Stream Key Derivation
|
|
|
|
### 4.1 Implementation
|
|
|
|
```go
|
|
// pkg/smsg/stream.go:49-60
|
|
func DeriveStreamKey(date, license, fingerprint string) []byte {
|
|
input := fmt.Sprintf("%s:%s:%s", date, license, fingerprint)
|
|
cryptService := crypt.NewService()
|
|
lthnHash := cryptService.Hash(crypt.LTHN, input)
|
|
key := sha256.Sum256([]byte(lthnHash))
|
|
return key[:]
|
|
}
|
|
```
|
|
|
|
### 4.2 Input Format
|
|
|
|
```
|
|
period:license:fingerprint
|
|
|
|
Where:
|
|
- period: Time period identifier (see Cadence)
|
|
- license: User's license key (password)
|
|
- fingerprint: Device/browser fingerprint
|
|
```
|
|
|
|
### 4.3 Output
|
|
|
|
32-byte key suitable for ChaCha20-Poly1305.
|
|
|
|
## 5. Cadence
|
|
|
|
### 5.1 Options
|
|
|
|
| Cadence | Constant | Period Format | Example | Duration |
|
|
|---------|----------|---------------|---------|----------|
|
|
| Daily | `CadenceDaily` | `2006-01-02` | `2026-01-13` | 24h |
|
|
| 12-hour | `CadenceHalfDay` | `2006-01-02-AM/PM` | `2026-01-13-PM` | 12h |
|
|
| 6-hour | `CadenceQuarter` | `2006-01-02-HH` | `2026-01-13-12` | 6h |
|
|
| Hourly | `CadenceHourly` | `2006-01-02-HH` | `2026-01-13-15` | 1h |
|
|
|
|
### 5.2 Period Calculation
|
|
|
|
```go
|
|
// pkg/smsg/stream.go:73-119
|
|
func GetCurrentPeriod(cadence Cadence) string {
|
|
return GetPeriodAt(time.Now(), cadence)
|
|
}
|
|
|
|
func GetPeriodAt(t time.Time, cadence Cadence) string {
|
|
switch cadence {
|
|
case CadenceDaily:
|
|
return t.Format("2006-01-02")
|
|
case CadenceHalfDay:
|
|
suffix := "AM"
|
|
if t.Hour() >= 12 {
|
|
suffix = "PM"
|
|
}
|
|
return t.Format("2006-01-02") + "-" + suffix
|
|
case CadenceQuarter:
|
|
bucket := (t.Hour() / 6) * 6
|
|
return fmt.Sprintf("%s-%02d", t.Format("2006-01-02"), bucket)
|
|
case CadenceHourly:
|
|
return fmt.Sprintf("%s-%02d", t.Format("2006-01-02"), t.Hour())
|
|
}
|
|
return t.Format("2006-01-02")
|
|
}
|
|
|
|
func GetNextPeriod(cadence Cadence) string {
|
|
return GetPeriodAt(time.Now().Add(GetCadenceDuration(cadence)), cadence)
|
|
}
|
|
```
|
|
|
|
### 5.3 Duration Mapping
|
|
|
|
```go
|
|
func GetCadenceDuration(cadence Cadence) time.Duration {
|
|
switch cadence {
|
|
case CadenceDaily:
|
|
return 24 * time.Hour
|
|
case CadenceHalfDay:
|
|
return 12 * time.Hour
|
|
case CadenceQuarter:
|
|
return 6 * time.Hour
|
|
case CadenceHourly:
|
|
return 1 * time.Hour
|
|
}
|
|
return 24 * time.Hour
|
|
}
|
|
```
|
|
|
|
## 6. Rolling Windows
|
|
|
|
### 6.1 Dual-Key Strategy
|
|
|
|
At encryption time, CEK is wrapped with **two** keys:
|
|
1. Current period key
|
|
2. Next period key
|
|
|
|
This creates a rolling validity window:
|
|
|
|
```
|
|
Time: 2026-01-13 23:30 (daily cadence)
|
|
|
|
Valid keys:
|
|
- "2026-01-13:license:fp" (current period)
|
|
- "2026-01-14:license:fp" (next period)
|
|
|
|
Window: 24-48 hours of validity
|
|
```
|
|
|
|
### 6.2 Key Wrapping
|
|
|
|
```go
|
|
// pkg/smsg/stream.go:135-155
|
|
func WrapCEK(cek []byte, streamKey []byte) (string, error) {
|
|
sigil := enchantrix.NewChaChaPolySigil()
|
|
wrapped, err := sigil.Seal(cek, streamKey)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return base64.StdEncoding.EncodeToString(wrapped), nil
|
|
}
|
|
```
|
|
|
|
**Wrapped format**:
|
|
```
|
|
[24-byte nonce][encrypted CEK][16-byte auth tag]
|
|
→ base64 encoded for header storage
|
|
```
|
|
|
|
### 6.3 Key Unwrapping
|
|
|
|
```go
|
|
// pkg/smsg/stream.go:157-170
|
|
func UnwrapCEK(wrapped string, streamKey []byte) ([]byte, error) {
|
|
data, err := base64.StdEncoding.DecodeString(wrapped)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sigil := enchantrix.NewChaChaPolySigil()
|
|
return sigil.Open(data, streamKey)
|
|
}
|
|
```
|
|
|
|
### 6.4 Decryption Flow
|
|
|
|
```go
|
|
// pkg/smsg/stream.go:606-633
|
|
func UnwrapCEKFromHeader(header *V3Header, params *StreamParams) ([]byte, error) {
|
|
// Try current period first
|
|
currentPeriod := GetCurrentPeriod(params.Cadence)
|
|
currentKey := DeriveStreamKey(currentPeriod, params.License, params.Fingerprint)
|
|
|
|
for _, wk := range header.WrappedKeys {
|
|
cek, err := UnwrapCEK(wk.Key, currentKey)
|
|
if err == nil {
|
|
return cek, nil
|
|
}
|
|
}
|
|
|
|
// Try next period (for clock skew)
|
|
nextPeriod := GetNextPeriod(params.Cadence)
|
|
nextKey := DeriveStreamKey(nextPeriod, params.License, params.Fingerprint)
|
|
|
|
for _, wk := range header.WrappedKeys {
|
|
cek, err := UnwrapCEK(wk.Key, nextKey)
|
|
if err == nil {
|
|
return cek, nil
|
|
}
|
|
}
|
|
|
|
return nil, ErrKeyExpired
|
|
}
|
|
```
|
|
|
|
## 7. V3 Header Format
|
|
|
|
```go
|
|
type V3Header struct {
|
|
Format string `json:"format"` // "v3"
|
|
Manifest *Manifest `json:"manifest"`
|
|
WrappedKeys []WrappedKey `json:"wrappedKeys"`
|
|
Chunked *ChunkInfo `json:"chunked,omitempty"`
|
|
}
|
|
|
|
type WrappedKey struct {
|
|
Period string `json:"period"` // e.g., "2026-01-13"
|
|
Key string `json:"key"` // base64-encoded wrapped CEK
|
|
}
|
|
```
|
|
|
|
## 8. Rainbow Table Resistance
|
|
|
|
### 8.1 Why It Works
|
|
|
|
Standard hash:
|
|
```
|
|
SHA256("2026-01-13:license:fp") → predictable, precomputable
|
|
```
|
|
|
|
LTHN hash:
|
|
```
|
|
LTHN("2026-01-13:license:fp")
|
|
= SHA256("2026-01-13:license:fp" + reverse_leet("2026-01-13:license:fp"))
|
|
= SHA256("2026-01-13:license:fp" + "pf:3zn3ci1:31-10-6202")
|
|
```
|
|
|
|
The salt is **derived from the input itself**, making precomputation impractical:
|
|
- Each unique input has a unique salt
|
|
- Cannot build rainbow tables without knowing all possible inputs
|
|
- Input space includes license keys (high entropy)
|
|
|
|
### 8.2 Security Analysis
|
|
|
|
| Attack | Mitigation |
|
|
|--------|------------|
|
|
| Rainbow tables | Input-derived salt makes precomputation infeasible |
|
|
| Brute force | License key entropy (64+ bits recommended) |
|
|
| Time oracle | Rolling window prevents precise timing attacks |
|
|
| Key sharing | Keys expire within cadence window |
|
|
|
|
## 9. Zero-Trust Properties
|
|
|
|
| Property | Implementation |
|
|
|----------|----------------|
|
|
| No key server | Keys derived locally from LTHN |
|
|
| Auto-expiration | Rolling periods invalidate old keys |
|
|
| No revocation | Keys naturally expire within cadence window |
|
|
| Device binding | Fingerprint in derivation input |
|
|
| User binding | License key in derivation input |
|
|
|
|
## 10. Test Vectors
|
|
|
|
From `pkg/smsg/stream_test.go`:
|
|
|
|
```go
|
|
// Stream key generation
|
|
date := "2026-01-12"
|
|
license := "test-license"
|
|
fingerprint := "test-fp"
|
|
key := DeriveStreamKey(date, license, fingerprint)
|
|
// key is 32 bytes, deterministic
|
|
|
|
// Period calculation at 2026-01-12 15:30:00 UTC
|
|
t := time.Date(2026, 1, 12, 15, 30, 0, 0, time.UTC)
|
|
|
|
GetPeriodAt(t, CadenceDaily) // "2026-01-12"
|
|
GetPeriodAt(t, CadenceHalfDay) // "2026-01-12-PM"
|
|
GetPeriodAt(t, CadenceQuarter) // "2026-01-12-12"
|
|
GetPeriodAt(t, CadenceHourly) // "2026-01-12-15"
|
|
|
|
// Next periods
|
|
// Daily: "2026-01-12" → "2026-01-13"
|
|
// 12h: "2026-01-12-PM" → "2026-01-13-AM"
|
|
// 6h: "2026-01-12-12" → "2026-01-12-18"
|
|
// 1h: "2026-01-12-15" → "2026-01-12-16"
|
|
```
|
|
|
|
## 11. Implementation Reference
|
|
|
|
- Stream key derivation: `pkg/smsg/stream.go`
|
|
- LTHN hash: `github.com/Snider/Enchantrix/pkg/crypt`
|
|
- WASM bindings: `pkg/wasm/stmf/main.go` (decryptV3, unwrapCEK)
|
|
- Tests: `pkg/smsg/stream_test.go`
|
|
|
|
## 12. Security Considerations
|
|
|
|
1. **License entropy**: Recommend 64+ bits (12+ alphanumeric chars)
|
|
2. **Fingerprint stability**: Should be stable but not user-controllable
|
|
3. **Clock skew**: Rolling windows handle ±1 period drift
|
|
4. **Key exposure**: Derived keys valid only for one period
|
|
|
|
## 13. References
|
|
|
|
- RFC-002: SMSG Format (v3 streaming)
|
|
- RFC-001: OSS DRM (Section 3.4)
|
|
- RFC 8439: ChaCha20-Poly1305
|
|
- Enchantrix: github.com/Snider/Enchantrix
|