Hash adapters provide tamper detection for stored achievement data. The engine computes a hash of data before storing and verifies it on read. If the hash doesn’t match, the data has been tampered with and is discarded.
HashAdapter Interface
type HashAdapter = {
hash(data: string): string;
}
A hash adapter takes a string and returns a hash string. The hash should be deterministic (same input = same output) and reasonably collision-resistant.
hash
(data: string) => string
required
Compute a hash of the input data. Should return a deterministic string.
fnv1aHashAdapter
function fnv1aHashAdapter(): HashAdapter
Default hash adapter using FNV-1a (32-bit). Fast, synchronous, and sufficient for tamper detection.
Hash adapter instance using FNV-1a algorithm
Example
import { createAchievements, fnv1aHashAdapter } from '@achievements-manager/core';
const engine = createAchievements({
definitions,
hash: fnv1aHashAdapter(), // This is the default
});
Behavior
- Algorithm: FNV-1a (Fowler-Noll-Vo hash function)
- Output: 32-bit hash as hexadecimal string
- Speed: Extremely fast (~millions of ops/sec)
- Collisions: Low probability for typical achievement data
- Cryptographic: No (not suitable for security, only tamper detection)
Implementation
export function fnv1aHashAdapter(): HashAdapter {
return {
hash(data: string): string {
let h = 2166136261; // FNV-1a offset basis
for (let i = 0; i < data.length; i++) {
h ^= data.charCodeAt(i);
h = Math.imul(h, 16777619) >>> 0; // FNV prime, keep 32-bit unsigned
}
return h.toString(16);
},
};
}
Example Output
const adapter = fnv1aHashAdapter();
adapter.hash('[]'); // "811c9dc5" (empty unlocked array)
adapter.hash('["first-login"]'); // "a1b2c3d4" (example hash)
adapter.hash('["first-login","complete-profile"]'); // "e5f6a7b8" (example hash)
Custom Hash Adapters
You can create custom adapters for stronger hashing or specialized use cases.
Crypto API Example (SHA-256)
import { HashAdapter } from '@achievements-manager/core';
function sha256HashAdapter(): HashAdapter {
return {
hash(data: string): string {
// Note: crypto.subtle.digest is async, so we use a synchronous alternative
// For actual SHA-256, you'd need a sync library or maintain a cache
// Example with a hypothetical sync SHA-256 library:
return sha256Sync(data);
},
};
}
Simple Checksum Example
import { HashAdapter } from '@achievements-manager/core';
function checksumAdapter(): HashAdapter {
return {
hash(data: string): string {
let sum = 0;
for (let i = 0; i < data.length; i++) {
sum += data.charCodeAt(i);
}
return sum.toString(36);
},
};
}
MurmurHash Example
import { HashAdapter } from '@achievements-manager/core';
import murmur from 'murmurhash-js';
function murmurHashAdapter(): HashAdapter {
return {
hash(data: string): string {
return murmur.murmur3(data).toString(16);
},
};
}
How Integrity Checking Works
For each storage key, the engine stores two items:
- The data itself (e.g.,
"unlocked" → "[\"first-login\"]")
- A hash of the data (e.g.,
"unlocked:hash" → "a1b2c3d4")
Write Flow
// User unlocks an achievement
engine.unlock('first-login');
// Engine serializes data
const data = JSON.stringify(['first-login']); // '["first-login"]'
// Engine computes hash
const hash = hashAdapter.hash(data); // 'a1b2c3d4'
// Engine stores both
storage.set('unlocked', data);
storage.set('unlocked:hash', hash);
Read Flow
// Engine loads data on initialization
const data = storage.get('unlocked'); // '["first-login"]'
const storedHash = storage.get('unlocked:hash'); // 'a1b2c3d4'
// Engine recomputes hash
const computedHash = hashAdapter.hash(data); // 'a1b2c3d4'
// Engine verifies integrity
if (storedHash === computedHash) {
// Data is intact, use it
unlockedIds = JSON.parse(data);
} else {
// Data was tampered with!
config.onTamperDetected?.('unlocked');
unlockedIds = new Set(); // Start fresh
}
Tamper Detection
If a user manually edits localStorage:
// User tries to cheat by editing localStorage
localStorage.setItem('unlocked', '["first-login","secret-achievement"]');
// But forgets to update the hash!
// On next page load:
// computedHash('[".."]') !== storedHash
// → onTamperDetected('unlocked') is called
// → All achievement data is wiped and reset to clean state
Security Considerations
Not Cryptographically Secure
The default fnv1aHashAdapter is not cryptographically secure. It’s designed for:
- Detecting accidental corruption
- Deterring casual tampering
- Fast integrity checks
It’s not suitable for:
- Preventing determined attackers
- Protecting sensitive data
- Cryptographic signatures
Client-Side Limitations
All client-side anti-cheat measures can be bypassed:
- Users can modify JavaScript code
- Users can compute valid hashes for fake data
- Users can disable integrity checks entirely
For server-authoritative achievements, validate unlocks on the backend.
When to Use Hash Adapters
Hash adapters are useful for:
- Single-player games (deter casual cheating)
- Progress tracking apps (detect corruption)
- Achievements for fun (not competitive)
Avoid relying on client-side integrity for:
- Leaderboards
- Competitive multiplayer
- Rewards with real value
Best Practices
Use the Default
For most use cases, fnv1aHashAdapter() is sufficient:
const engine = createAchievements({
definitions,
// No need to specify hash — fnv1aHashAdapter is the default
});
Handle Tamper Detection
Always provide an onTamperDetected callback:
const engine = createAchievements({
definitions,
onTamperDetected: (key) => {
console.warn(`Tampered data detected: ${key}`);
// Optional: log to analytics, show warning to user, etc.
},
});
Testing
When testing, you can use a no-op hash adapter to disable integrity checks:
const noOpHashAdapter = (): HashAdapter => ({
hash: () => 'noop',
});
const engine = createAchievements({
definitions,
hash: noOpHashAdapter(), // No integrity checks
});
Stronger Hashing
If you need stronger tamper resistance, use a cryptographic hash:
import { createAchievements } from '@achievements-manager/core';
import { sha256 } from 'crypto-js';
const engine = createAchievements({
definitions,
hash: {
hash: (data) => sha256(data).toString(),
},
});
Note: This only raises the bar — determined users can still bypass it.
Migration from Unhashed Data
The engine is backward-compatible with data stored before integrity checks were added:
// Old data (no hash key):
localStorage.setItem('unlocked', '["first-login"]');
// No 'unlocked:hash' key exists
// Engine behavior:
const storedHash = storage.get('unlocked:hash'); // null
if (storedHash === null) {
// No hash stored yet — trust the data as-is
// This allows smooth migration from pre-anti-cheat versions
}
// Next write will add the hash:
engine.unlock('second-login');
// Now both 'unlocked' and 'unlocked:hash' exist
This ensures existing users don’t lose their progress when you add integrity checks.
See Also