# DucoBox local API — Api-Key algorithm

How the `Api-Key` header for the **local** DucoBox API (`http(s)://<box-ip>/`) is produced.
Reverse-engineered from `io.owow.duco.installer` 5.3 (versionCode 2272776).

Classes involved (obfuscated → original, recovered from surviving `SourceFile` attributes):

| obfuscated | source | role |
|---|---|---|
| `LF6/a;` | `APIKeyHeaderInterceptor.kt` | OkHttp interceptor that sets the header |
| `LB7/c;` | `DynamicApiKeyGenerator.kt` | the mixing algorithm (`mix`) |
| `LO6/f;` | `DynamicApiKeyInfo.kt` | holds the inputs |
| `LV/f0;` | (util) `N()` | local unix time in seconds |

## Core idea

The key is **computed entirely on the device**. There is no challenge/response and no
token exchange with the box or the cloud. It is security-through-obscurity: anyone with
the APK has both the seed and the algorithm, and the box firmware runs the same
computation to validate.

The header is set on **every** local request:

```
Api-Key: <64 alphanumeric characters>
```

## Inputs

Two values, both obtainable from the box via `GET /info`:

| input | source (`GET /info`) | example |
|---|---|---|
| `serialBoardBox` | `General.Board.SerialBoardBox` | `RS8358405328` |
| `macAddress` | `General.Lan.Mac` | `aa:bb:cc:dd:ee:ff` (lower-case) |

The strings are used **raw** (`charAt` per character), so the exact format matters. In
practice the MAC must be **lower-case** (with colons): a key built from an upper-case MAC
is rejected by the box. The tools lower-case the MAC automatically.

In code these arrive as `DynamicApiKeyInfo(serialDucoBox, serialBoardBox, macAddress)`,
after which `DynamicApiKeyGenerator(boardSerial = serialBoardBox, macAddress = macAddress)`
is constructed (`LY6/M::i`). The third field `serialDucoBox` is only used elsewhere as an
identifier (`code`), not in the key.

## The mixing function `mix(x, y)`

Combines two characters (UTF-16 code units) into one alphanumeric character from the set
`[0-9 A-Z a-z]`. Port of `DynamicApiKeyGenerator.a(gen, char, char): char`:

```
c = (x XOR y) & 0x7F          # 7 bits, i.e. 0..127

c < 48  ('0')      -> c % 26 + 97   ('a'..'z')
48..57  ('0'..'9') -> c             (digit, unchanged)
58..64             -> c % 26 + 65   ('A'..'Z')
65..90  ('A'..'Z') -> c             (upper-case, unchanged)
91..96             -> c % 10 + 48   ('0'..'9')
97..122 ('a'..'z') -> c             (lower-case, unchanged)
123..127           -> c % 10 + 48   ('0'..'9')
```

Effect: already-alphanumeric values are kept; everything else is folded back into one of
the three ranges via a modulo. The `gen` parameter is passed only for a null check and
does not affect the result.

## The build algorithm

Start from the fixed 64-character seed embedded in the APK (`APIKeyHeaderInterceptor.kt`):

```
n4W2lNnb2IPnfBrXwSTzTlvmDvsbemYRvXBRWrfNtQJlMiQ8yPVRmGcoPd7szSu2
```

Transform the `key` array (64 chars) in three steps:

**Step 1 — mix in the MAC** (positions `0 … len(mac)-1`):

```
for i in 0 .. len(macAddress)-1:
    key[i] = mix(key[i], macAddress[i])
```

**Step 2 — mix in the serial** (positions `32 … 32+len(serial)-1`):

```
for i in 0 .. len(serialBoardBox)-1:
    key[i+32] = mix(key[i+32], serialBoardBox[i])
```

**Step 3 — daily shuffle.** `day = localEpochSeconds // 86400`. Sixteen bits of `day` are
walked; only on a set bit is a block of four positions mixed together (with mirror
position `63-j`):

```
day = localEpochSeconds // 86400
for i in 0 .. 15:
    if (day & (1 << i)) != 0:
        base = 4*i
        j    = 2*i
        key[base]   = mix(key[base],   key[j+32])
        key[base+1] = mix(key[base+1], key[63-j])
        key[base+2] = mix(key[base],   key[base+1])   # uses the updated values
        key[base+3] = mix(key[base+1], key[base+2])
```

The result `new String(key)` is the `Api-Key`.

## Time and rotation

`localEpochSeconds` comes from `V.f0.N()`:

```
utc      = System.currentTimeMillis() / 1000
offset   = TimeZone.getDefault().getOffset(now) / 1000   # seconds, + east of UTC
localSec = utc + offset
```

Because step 3 runs on `day = localSec // 86400`, the key **changes every calendar day**
(in the device's local time zone). Device and box must therefore agree on the day — which
is why there is a separate `DucoBoxTimeInterceptor` that corrects the box clock via
`POST /action` (SetTime) when it drifts too far.

## Bootstrap: where do serial + MAC come from?

The app reads the inputs from the box before any key exists. It can, because the service
factory (`BoxServiceFactoryImpl`, `LI6/j;`) can build three clients:

| method | Api-Key interceptor | use |
|---|---|---|
| `a(service, apiKeyGenerator, network)` | **yes** | normal, authenticated calls |
| `b(service, apiKeyGenerator, interceptor, network)` | yes (+ extra) | same, with extra interceptor |
| `c(service, network)` | **no** | bootstrap — keyless |

With the keyless client (`c`) the app reads three values via `GET /info`
(`DucoBoxConnection` → `LP6/j;`):

- `General.Board.SerialDucoBox` (identifier / `code` only)
- `General.Board.SerialBoardBox` → `boardSerial`
- `General.Lan.Mac` → `macAddress`

From these `DynamicApiKeyInfo` and then the generator are built. So **`GET /info` does not
require a key** for these fields — only write actions (`PATCH`, `POST /action`, firmware)
go through the key interceptor. This is what makes fully automatic bootstrapping possible
(see `duco_apikey_fetch.py`).

## Security observations

- No real cryptographic secret: the seed is in clear text in the APK and the algorithm is
  trivial to reproduce.
- The "key" is deterministically derived from box-owned, queryable data (serial + MAC)
  plus the day. Anyone who can reach the box and read `GET /info` can also assemble a
  valid `Api-Key`.
- The daily rotation adds no real security; it is a form of obfuscation, not
  authentication.

The full decompiled bytecode of `DynamicApiKeyGenerator`, `APIKeyHeaderInterceptor` and
the time source is in [`apikey_bytecode.txt`](apikey_bytecode.txt).
