Changelog¶
0.1.0 — 2026-06-07¶
First published release (npm). Packages everything described under
V1.0 — proof of life below plus the
Unreleased hardening that landed on top of it:
on-device signer_sha256 enforcement, the Zod map-input security
bounds, the CLI hardening (no TS/JS-module ingestion, path
containment), and the least-privilege CI workflow. Pre-1.0: the
public surface may still shift before 1.0.0.
Unreleased¶
Map schema 3 → 4 (breaking)¶
The published map is now a pure real→obfuscated mapping. The hard
literal gate (z.literal(4)) rejects schema_version: 3 (and 2/1)
maps — re-emit at version 4. Changes (tracking the canonical rosetta-maps
schema):
- Removed every finding-evidence / AIDL field no resolver read:
methodEntry.aidl_txn,classEntry.aidl_descriptor, theclassEntry.anchorsarray, and theaidl_stub/aidl_callbackvalues from the classkindenum (sokindis now onlyclass | interface | enum | synthetic | anonymous). An AIDL stub iskind: class; a callback interface iskind: interface. These belong in the signatures authoring source, never the emitted map. - Health check simplified to the target-namespace guard plus a
Java.use(obfName)load — there is no longer descriptor/anchor metadata in the map to assert against. rosetta validate --deepdropped theaidl_txn-collision semantic check (the field is gone); it still flags duplicate obfuscated names per dex and unparseable signatures.- The retained structural fields (
extends, the remainingkindvalues,dex) are unchanged.
Removed the .sha256 byte-hash sidecar from rosetta pull (#21)¶
rosetta pull no longer fetches or verifies a detached
<version_code>.json.sha256 byte-hash sidecar, and the --require-sidecar
flag (and PullConfig.requireSidecar) are gone. A map is pure data the
resolver only reads to point at a member that already exists in the running
app — nothing in a map is executed — so a tampered map is at worst a
correctness bug, never code execution. Transport integrity already comes
for free from the channel: maps are pulled over git-over-HTTPS from a
content-addressed (SHA-named) store. The sidecar restated that guarantee
without strengthening it (maps#37). signer_sha256 is unaffected — it is an
unrelated in-map version guard, not a transport-integrity control. The
single source of truth for the safety model is the rosetta-maps
docs/reference/integrity.md.
Map schema 2 → 3 (breaking)¶
The map format moved to schema_version: 3. The hard literal gate
(z.literal(3)) rejects schema_version: 1 and 2 maps — re-emit at
version 3. Changes (tracking the canonical rosetta-maps schema):
- Removed
confidence(#43) fromsources[]and class entries. The strict objects now reject it as an unknown key. No replacement. captured_atis an ISO date (YYYY-MM-DD, #39). Arbitrary text is now rejected.signer_sha256may be an array (#38, #32). Either a single bare-lowercase 64-hex digest or a non-empty array of them; a live signer matching any entry passes (covers key-rotation lineages). TheSignerCheckResultgainsexpectedHashes(the normalized match-any set).- Added
generated_from(#36):{ signatures_rev }, a 7–40-char git commit hash pointing back at the signatures revision a map was generated from. Optional; required-if-present. - Added lifecycle
status(#40):active(default) /superseded/retracted, plus an optionalsuperseded_byversion_code. Asupersededmap loads with a newmap-statuswarning event; aretractedmap is refused fail-closed with the newMapRetractedError.
New public surface: MapStatusEvent, MapRetractedError, the
GeneratedFrom / MapStatus types.
CLI (map-authoring verbs)¶
- Library-first parity for the V1.5 verbs. The pure cores of
diff,merge,types, and the semantic checks were extracted out ofcli/commands/*intosrc/and re-exported fromsrc/index.ts, so they are usable programmatically (matchingconvert):diffMaps/renderHumanDiff(src/diff/),mergeMaps(src/merge/),verifyMap(src/verify/), andrenderTypes/collectNames(src/types-emit/). The CLI files are now thin arg-parse + IO wrappers. - Dropped the
merge-bundlealias. It was a verbatim duplicate ofmerge(and leaked its own name into the success line). Onlymergeremains. - Folded
verifyintovalidate --deep. The standaloneverifyverb took the same input, output shape, and exit codes asvalidateand differed only by check depth, so it became a--deep(alias--semantic) flag onvalidate.--jsonemits the structured findings for CI.verifyMapstays exported for programmatic use. mergeMapsnow takes an options object ({ strict, onOverride }) instead of a positional boolean. In non-strict mode each last-wins override of an obfuscated name — the "silent wrong name corrupts hooks" hazard — emits anote:line to stderr. The per-class scalar fold now stripsundefinedlike the top-level path, so an explicitextends: undefinedon a later input can no longer erase a base value.diffgained--exit-code(CI "fail if the map rotated": exit 1 on a non-empty diff; default stays 0). The human header now shows both maps'versionlabels when present, anddiffMapsasserts both maps describe the same app so a direct caller cannot silently mislabel the diff.
CLI (correctness fixes)¶
types: valid TypeScript for schema-legal names. String literals are now rendered withJSON.stringify(double-quoted, fully escaped) so a class/method/field name containing'or\no longer emits a syntax error; the generated JSDoc header sanitizes a block-comment terminator out of the interpolatedapp/versionso a hostileversioncannot break out of the comment.validate --deep: fewer false positives, no fail-hard on heuristics. The dangling-extends/ un-translated-arg-type heuristic now matches against the fullapppackage prefix (not a 2-segment slice), eliminating cross-namespace false positives on real vendor apps (e.g. acom.google.android.apps.*app referencingcom.google.android.gms.*). The semantic findings are classified by severity: duplicate obfuscated names per dex and unparseable signatures are HARD errors (exit 1); the heuristic cross-references are WARNINGS that are reported but never fail the build (VerifyIssuegained aseverityfield).
Runtime¶
- Expanded fuzzy version matching (
src/session/version-match.ts,src/config.ts; issue #22) —versionMatchnow also accepts a richer object form (VersionMatchConfig) alongside the legacy'exact'/'fuzzy'strings: an opt-in numericversionCodeRangeover the authoritativeversion_code, an opt-in semver-ishversionRangeover the label, amaxDistanceceiling that makes a too-far nearest-label pick fail loudly, and arankedflag that exposes the full ranked candidate list. The same shape is the new typed-config default (RosettaConfig.versionMatching, validated by one shared Zod schema and consultable viaSessionOptions.config). Exactversion_codestays the default and highest-precedence selection; every new knob is strictly opt-in and a miss with fuzzy disabled still throws the sameno map for version '…'error (RFC 0001 Decision 3 preserved). Moves the V1.5-roadmap item out of deferred.
Review follow-ups (issue #22):
- maxDistance is now a major-dominant lexicographic ceiling,
consistent with the candidate ranking — a pick is accepted only when its
distance [Δmajor, Δminor, Δpatch] is <= [maxDistance, 0, 0]. The old
per-component check was inconsistent (it rejected [0,0,5] yet accepted
[1,0,0] at maxDistance: 1). It is explicitly a label-distance
ceiling: it now also gates the versionRange (label-range) tier, and is
rejected at config time when paired with only a versionCodeRange
(a no-op, since that tier ranks by a different metric).
- Parse-time range validation hardened: an inverted versionRange
(min > max, compared on the same parsed tuple as selection) and an
all-undefined range ({}, which silently matched every map) are now
rejected with a clear error instead of surfacing later as a misleading
"no map".
- map-load now carries a selectionKind ('exact' | 'nearest' |
'code-range' | 'label-range'), threaded through the pick result and the
post-pick acceptance check, so the five selection tiers stay
distinguishable rather than collapsing to one fuzzy boolean — a
deliberate (possibly far) range pick is visible to callers and events.
- Documented that ranges are independently opt-in: any one of
strategy: 'fuzzy', versionCodeRange, or versionRange engages
approximate selection on its own; a range works under strategy:
'exact'/omitted by design.
- Internal: extracted a single shared compareTuple / parseVersion
(src/session/version-tuple.ts) used by both the config range
refinements and the runtime pick, and a guarded getMap registry
accessor — removing duplicate comparators and as RosettaMap casts.
- On-device
signer_sha256enforcement (src/session/signer-detect.ts) — when the loaded map carries asigner_sha256,rosetta.session(...)now reads the running app's signing certificate in-process (GET_SIGNING_CERTIFICATES→signingInfo.apkContentsSignerson API 28+,GET_SIGNATURES→packageInfo.signaturesas the pre-28 fallback), SHA-256's each certificate, and fails closed with the newSignerMismatchErrorif no live signer matches. A match on any one of several signers passes (key-rotation lineage). The check runs after version selection and before the health check, emits a structuredsigner-checkdiagnostic event, and is gated by the newSessionOptions.enforceSignerknob (defaulttrue, the secure default; setfalseto opt out). When the map has nosigner_sha256the check is skipped entirely. Moves the V2-roadmap item out of planned.
Validation (security bounds)¶
- Map input bounds + key safety (
src/validate/schema.ts) — the Zod validator now enforces size/cardinality caps, stringmaxLengths, anapppackage-name pattern, aversion_codeceiling, anextendscap, asigner_sha25664-hex pattern, and rejection of reserved keys (__proto__/constructor/prototype) — mirroring the canonical rosetta-maps JSON Schema so all clients agree. version_codewidened to the full 64-bitlongVersionCode(rosetta-maps#8). The cap moved from the int32 max (2^31 − 1) toNumber.MAX_SAFE_INTEGER(2^53 − 1) across the schema, Zod (MAX_VERSION_CODE), and the Kotlin client. Android'slongVersionCodeis(versionCodeMajor << 32) | versionCode; apps that setversionCodeMajorexceeded the old cap and their maps were silently unselectable. The value is never masked to its low 32 bits (that would alias distinct releases).src/session/auto-detect.tsnow reads the fulllongVersionCodeand fails loudly instead of truncating if a bridge ever returns a value above 2^53 − 1. See RFC 0001 Decision 3.
CLI security hardening¶
- Removed TS/JS-module map ingestion (build-time RCE).
rosetta convert/validateno longer accept.ts/.js/.mjs/.cjsinputs. They used to be loaded via dynamicimport(), executing arbitrary contributor-supplied code before validation. Maps are pure data — author them as JSON or YAML. Module inputs are now refused with a clear error, never imported.tsModuleToMapis gone;convertToJsonaccepts YAML only. - Path containment for CLI writers (arbitrary file write). All
commands that build an on-disk path (
init,convert,extract,patch) now validate theapp/versionidentity tokens and contain every output path to the project tree (CWD). Traversal (../…), absolute escapes, and NUL bytes are refused before any IO. Seesrc/parse/paths.ts.
CI / supply chain¶
- Least-privilege workflow permissions; the APK-building
pipelinejob now runs on all-branch PRs as an advisory (continue-on-error) check while the SDK-freeverify(withaidl:lint) stays the required gate; apktool downloads and third-party actions are pinned by SHA-256 / commit SHA.
V1.0 — proof of life¶
The first complete release. Every subsystem the strategic design called out is implemented and tested.
Runtime¶
- Resolver (
src/resolver/) — real → obfuscated translation with a memoized per-session cache, reverse-index for type translation, and runtimeoverride(...)support. ThrowsResolveErrorin strict mode; returns a sentinel in warn mode. - Sentinels (
makeSentinel,isSentinel) — thewarn-policy deferred-error path. Sentinels throwUnresolvedAccessErrorwhen actually used. - Diagnostics (
src/log.ts,src/diagnostics/) — typedEventBuswithon(...)/onType(...)subscription, trace formatter,createSilentBus()helper. - Session (
src/session/) — full lifecycle. Auto-detect via in-processActivityThread.currentApplication().getApplicationContext(). getPackageManager().getPackageInfo(...). Registry-bundle version picking with optionalversionMatch: 'fuzzy'. Attach-time health check with configurable threshold. Failure policiesstrictandwarn. Trace mode.
Proxy layer¶
ClassProxy— wrapsJava.use(obfName); translates method and (static) field access by real name; exposes$realName,$obfName,$native,$resolverintrospection properties;$new(...)constructs instance proxies.MethodHandle—.overload(...)with arg-type translation;.implementation =setter on the auto-picked or selected overload;.overloadsarray.FieldAccessor—{ value: T }shape mirroring Frida.InstanceProxy— wraps an instance for translated field access.
Tier 1 — declarative¶
rosetta.hook(target, impl)— declarative method-hook installation. Both string form ('Class.method') and object form ({ class, method, args }) supported. Returns aHookHandlewith.detach().rosetta.proceed(...args)— call next-in-chain from inside a hook body. Stack-based context tracking; nested hooks work naturally.rosetta.field(instance, name)— read an instance field by real name.rosetta.setField(instance, name, value)— write an instance field by real name.
Tier 2 — Java.use-shaped¶
rosetta.use(realName)— resolve a class to aClassProxy.rosetta.type(realName)— translate a single real type to obfuscated (or pass through for primitives / framework / unmapped).
Tier 3 — escape hatches¶
rosetta.map.resolveClass(name),resolveMethod(class, name, argTypes?),resolveField(class, name).rosetta.map.override(name, entry)— install a runtime override.rosetta.map.extract()— return the boundRosettaMap.rosetta.events.on(fn),onType(type, fn)— subscribe to diagnostic events.
Canonical namespace¶
rosetta— the single ambient namespace tying tier-½/3 together. Set viarosetta.session(...). The composition uses a module-level singleton; explicit composition still works via direct imports (use,hook,createMapApi, …).
Map format¶
- Schema v2 —
schema_version: 2mandatory. Adds the required authoritativeversion_codekey and the optionalsigner_sha256authenticity guard; dropsapk_sha256. Validated by Zod. - Strict JSON — canonical on-disk format (no comments / trailing
commas). Comment-bearing YAML is the authoring input rendered to
JSON via
rosetta convert. (V1.0 also shipped a TS-module input; it was removed in Unreleased above — build-time RCE.) - YAML converter —
yamlToMap(...)via theyamlpackage. - Single-map and registry forms —
RosettaMapandRosettaMapRegistry. - 15-class anonymized sample map at
maps/com.example.app/30405.jsoncovering AIDL stubs, callbacks, overloads, fields, constructors, enums, synthetic Companions, anonymous inner classes.
Marker block¶
- PEM-style markers —
-----BEGIN ROSETTA MAP-----/-----END ROSETTA MAP-----(andMAP REGISTRYvariant). /*! ... */block comments preserve through minifiers.emitMarkerBlock,emitMarkerRegistry,parseMarkerBlock,patchMarkerBlock— full programmatic API.MARKER_REGEXexported for downstream regex tooling.- V2+ placeholder form reserved in the spec (not implemented).
CLI¶
The rosetta binary, seven commands:
init <app> <version> --version-code <n>— scaffold a new JSON map (the--version-codeis required and becomes the filename).pull <app>@<version_code>— fetch + schema-validate + identity-cross-check the verified map for an(app, version_code)from the communityrosetta-mapsrepo and write it into the project. Build-time only — never fetched on the device.validate <map>— schema + sanity check. Auto-detects JSON / YAML from extension.convert <in> -o <out>— YAML → canonical JSON.patch <bundle.js> --map <new>— replace embedded map in a compiled bundle. In-place by default.extract <bundle.js> -o <out>— pull the embedded map back out.inspect <bundle.js>— one-line summary of embedded map.
Error hierarchy¶
Nine error classes, all subclasses of RosettaError:
ResolveError— class/method/field not in map.AmbiguousOverloadError— multi-overload method, string form.MapValidationError— schema failure; carries structuredissues.JsonParseError— JSON source syntax error; carriesline/col.MapVersionMismatchError— loaded map doesn't match detected app/version.HealthCheckFailedError— attach-time check failed, strict mode.MarkerBlockError— bundle has no marker block or malformed one.UnresolvedAccessError— warn-mode sentinel actually used.
Tests¶
- 100% line / branch / function / statement coverage (the gate excludes barrels / pure type declarations / the CLI entry shim; see the repository's CI for the current test count).
- Test pattern: dependency-injected
Java.use/fs; each subsystem unit-testable in isolation.
Documentation¶
- Per-API docs (this site).
- Annotated sample hook at
examples/sample-hook/. - README at the package root.
What's coming in V1.5¶
Not in V1.0; tracked for the next release:
rosetta diff <a.json> <b.json>— show rotation deltas between versions.rosetta merge <a.json> <b.json> [...]— merge partial maps.rosetta merge-bundle <bundle.js> <maps...> -o <out>— single-map → registry bundle.rosetta types <map.json> -o <out.d.ts>— generate per-map TS declarations.rosetta migrate <map.json>— schema migrators (e.g. for a future v3 bump; the 1→2 change was a hard cutover).rosetta verify --device <id>— live health check viafrida-server.frida-compileplugin for auto-marker-wrapping.- Multi-session support on the
rosettanamespace.
What's coming in V2¶
- Public maps repo (
rosetta-frida-maps) — community-contributed obfuscation maps validated by CI. - Runtime injection (
rosetta.injectMap(...)) — populate the marker block's V2 placeholder form at attach time. - Self-healing discovery — strategy registry that runs when the map misses. Includes AIDL-descriptor matching, signature scan in known class, superclass match, stable-string anchor.
What's coming in V3¶
- Native (JNI / ELF symbol) mapping.
- Non-Frida runtimes (Xposed, ART, Riru, Zygisk).
- AI-assisted mapping generation.
- Hosted resolution service.
- IDE plugin (VSCode hints for obfuscated-name overlays, map coverage warnings, go-to-definition in jadx output).