# Photress Portable Backup Format V1

> Public interoperability specification 1.0 — frozen 7 August 2026  
> Canonical format identifier: `com.photress.backup.v1`  
> Package extension: `.photress`  
> Minimum reader version: `1`

## 1. Scope and conformance

This document defines the portable Photress V1 backup package read contract.
It is intended for independent, offline, read-only recovery implementations.
Security does not depend on this format being secret.

The words **MUST**, **MUST NOT**, **SHOULD**, and **MAY** are normative. A V1
reader MUST fail closed when a required identifier, field, authenticated
binding, length, digest, reference, or semantic invariant is not recognized.
It MUST NOT guess, select a branch by time, or mutate a package it cannot fully
understand.

A conforming V1 recovery reader:

- accepts a Backup Passphrase or a 256-bit Recovery Key;
- never accepts the device-local numeric App Code for a copied package;
- authenticates the complete selected head → commit → snapshot → manifest
  graph before presenting or exporting it;
- verifies every encrypted frame, reference digest, payload digest, byte count,
  and resource assembly during export;
- preserves every authenticated branch and treats sibling tips as a fork;
- performs read-only access to the source package.

## 2. Security and privacy model

The Backup Package Master Key (BPMK) protects graph metadata and wraps payload
keys. Payload bytes use random per-pack or per-resource 256-bit keys. The BPMK
is independently wrapped by the active Backup Passphrase envelope and/or the
active Recovery Key envelope.

Primary and Decoy libraries use separate package roots, random Library domain
identifiers, BPMKs, payload keys, graphs, and recovery operations. Implementers
MUST NOT infer or encode a user-facing Library role in portable filenames,
errors, logs, or unauthenticated metadata.

The package root, its directory names, object sizes, counts of opaque files,
and modification timing are visible to the storage provider. Media content,
filenames, album/folder names, item metadata, payload keys, and graph semantics
remain encrypted. Recovery software MUST NOT log credentials, keys, decrypted
metadata, original filenames, raw identifiers, or sensitive paths.

## 3. Primitive types and encoding

All structured binary values use RFC 8949 deterministic CBOR with the preferred
shortest integer/length representation and deterministic map-key order. Readers
MUST reject duplicate keys, indefinite-length items, non-minimal integers,
unexpected fields, trailing bytes, non-canonical encodings, and values outside
the declared bounds.

Notation used below:

- `bstr16` — exactly 16 bytes;
- `bstr32` — exactly 32 bytes;
- `u64` — unsigned 64-bit integer;
- `i64` — signed 64-bit integer using CBOR major type 0 or 1;
- `bool`, `text`, `null` — the corresponding CBOR primitive;
- `array[...]` — fixed or bounded CBOR array;
- `map{integer: value}` — CBOR map whose keys are the listed unsigned integers.

Identifiers are random 16-byte values. Digests are SHA-256 over the exact bytes
specified. Timestamps are signed Unix milliseconds in UTC. On disk, artifact
identifiers are 32 lowercase hexadecimal characters.

## 4. Frozen identifiers and algorithms

| Purpose | V1 value |
|---|---|
| Package magic | bytes `PHOTRESS` |
| Pack magic | bytes `PHOTPK01` |
| UTI | `com.photress.backup-package` |
| Format | `com.photress.backup.v1` |
| Header | `com.photress.backup.header.v1` |
| Algorithm suite | `com.photress.backup.algorithm-suite.v1` |
| Layout | `hybrid` |
| Sealed frame | `com.photress.backup.sealed-frame.v1` |
| BPMK envelope | `com.photress.backup.bpmk-envelope.v1` |
| Envelope registry | `com.photress.backup.key-envelope-set.v1` |
| Pack index | `com.photress.backup.pack-index.v1` |
| Manifest section/root | `com.photress.backup.manifest-section.v1` / `com.photress.backup.manifest-root.v1` |
| Snapshot/commit/head | `com.photress.backup.snapshot.v1` / `com.photress.backup.commit.v1` / `com.photress.backup.head.v1` |

V1 uses AES-256-GCM, SHA-256, Argon2id v1.3, HKDF-SHA-256, and deterministic
CBOR. The identifier `zstd` is reserved in the algorithm registry, but V1
production manifest sections MUST use compression value `0` (`none`). A V1
reader MUST reject value `1` (`zstd`) until a later explicitly versioned reader
contract enables it.

## 5. Package root

A package is a directory whose name ends in `.photress`. Its root contains only:

```text
<name>.photress/
├── format-header.cbor       required regular file
├── key-envelopes.cbor       required for portable unlock
├── HEAD                     optional reserved convenience file
├── commits/                 required directory
├── heads/                   required directory
├── snapshots/               required directory
├── manifests/               required directory
├── objects/                 required directory
├── packs/                   required directory
├── reports/                 required directory
├── tmp/                     required directory
└── advisory/                required directory
```

Checked root entries MUST be direct non-symlink regular files or directories as
declared. Unknown root entries are invalid in V1. `HEAD` is non-authoritative
and reserved; readers discover authenticated candidates from `heads/*.head`.
Files in `tmp/`, `reports/`, and `advisory/` never confer committed authority.

Artifact names are `<identifier-hex>.<extension>` using:

- `packs/*.pack`;
- `objects/*.chunk`;
- `manifests/*.manifest` for both section and root artifacts;
- `snapshots/*.snapshot`;
- `commits/*.commit`;
- `heads/*.head`.

## 6. Format header

`format-header.cbor` is the following exact canonical map:

| Key | Type | Required value |
|---:|---|---|
| 0 | bytes | `PHOTRESS` |
| 1 | text | `com.photress.backup.v1` |
| 2 | text | `com.photress.backup.header.v1` |
| 3 | u64 | format version `1` |
| 4 | u64 | minimum reader version `1` |
| 5 | bstr16 | package identifier |
| 6 | text | `com.photress.backup.algorithm-suite.v1` |
| 7 | text | `hybrid` |
| 8 | u64 | maximum packed-resource plaintext: `16777216` |
| 9 | u64 | maximum pack plaintext: `67108864` |
| 10 | u64 | maximum resources per pack: `32` |
| 11 | u64 | maximum standalone-chunk plaintext: `16777216` |

Every field and limit is part of compatibility. A V1 reader MUST reject a
header with a missing, extra, differently typed, or different-valued field.

## 7. Authenticated data and sealed frames

### 7.1 Common authenticated data

Every AES-GCM frame uses external authenticated data (AAD), encoded as the
canonical CBOR array:

```text
[
  domain: text,
  "com.photress.backup.v1": text,
  1: u64,
  "aes-256-gcm": text,
  packageIdentifier: bstr16,
  libraryDomainIdentifier: bstr16,
  artifactIdentifier: bstr16,
  ordinal: u64
]
```

The frozen domains are:

```text
photress/backup/bpmk-passphrase-envelope/v1
photress/backup/bpmk-recovery-envelope/v1
photress/backup/payload-key-envelope/v1
photress/backup/manifest/v1
photress/backup/snapshot/v1
photress/backup/commit/v1
photress/backup/head/v1
photress/backup/pack-index/v1
photress/backup/pack-entry/v1
photress/backup/resource-chunk/v1
photress/backup/verification-report/v1
```

Only the two BPMK-envelope domains append three additional elements:

```text
headerSHA256: bstr32,
keySlotIdentifier: bstr16,
kdfParameterSHA256: bstr32
```

`headerSHA256` hashes the exact canonical header bytes. `kdfParameterSHA256`
hashes the canonical array `[envelopeSchema, schemaVersion, factor, derivation,
salt, operationsLimit, memoryLimitBytes, artifactIdentifier,
keySlotIdentifier, generation]`.

### 7.2 AES-256-GCM frame

The key is 32 bytes, nonce is 12 bytes, and tag is 16 bytes. A sealed frame is:

| Key | Type | Meaning |
|---:|---|---|
| 0 | text | `com.photress.backup.sealed-frame.v1` |
| 1 | text | `aes-256-gcm` |
| 2 | bytes | 12-byte nonce |
| 3 | bytes | ciphertext |
| 4 | bytes | 16-byte tag |
| 5 | u64 | exact plaintext byte count |

The declared plaintext count MUST equal the ciphertext count and remain within
the artifact-specific bound before decryption is attempted.

## 8. Portable BPMK envelopes

### 8.1 Credential factors

`backup-passphrase` uses `argon2id-v1.3`; its salt, operations limit, and memory
limit are stored in the envelope and authenticated. The stored values MUST pass
the bounded V1 profile: a 16-byte salt, operations limit from 3 through 12, and
64–256 MiB of memory in 1024-byte increments. Photress setup accepts a
non-numeric UTF-8 passphrase with at least 8 characters. Six unrelated words
are recommended. Readers MUST allow an existing non-empty passphrase
even if a later setup policy becomes stricter.

`recovery-key` uses 32 bytes of entropy with `hkdf-sha-256`. Its envelope stores
operations and memory limits as zero. The human form is 24 English BIP39 words:
256 entropy bits followed by the first 8 bits of SHA-256 as checksum, split into
24 11-bit indices. The pinned 2048-word list SHA-256 is
`2f5eed53a4727b4bf8880d8f3f199efc90e58503646d9ff8eff3a2ed3b24dbda`.

The HKDF info is canonical CBOR:

```text
[
  "photress/backup/bpmk-recovery-envelope/v1",
  "com.photress.backup.bpmk-envelope.v1",
  1,
  packageIdentifier,
  libraryDomainIdentifier,
  artifactIdentifier,
  keySlotIdentifier,
  generation,
  SHA256(format-header.cbor)
]
```

### 8.2 Envelope schema

Each BPMK envelope is the exact canonical map:

| Key | Type | Meaning |
|---:|---|---|
| 0 | text | `com.photress.backup.bpmk-envelope.v1` |
| 1 | u64 | `1` |
| 2 | text | `aes-256-gcm` |
| 3 | text | `backup-passphrase` or `recovery-key` |
| 4 | text | `argon2id-v1.3` or `hkdf-sha-256` |
| 5 | bytes | derivation salt |
| 6 | u64 | operations limit; zero for Recovery Key |
| 7 | u64 | memory bytes; zero for Recovery Key |
| 8 | bstr16 | envelope artifact identifier |
| 9 | bstr16 | stable factor key-slot identifier |
| 10 | u64 | generation |
| 11 | bytes | AES-GCM sealed frame containing the 32-byte BPMK |

### 8.3 `key-envelopes.cbor`

The registry is canonical `map{0: schema, 1: 1, 2: format, 3:
packageIdentifier, 4: libraryDomainIdentifier, 5: entries}` where each entry is
`map{0: envelopeBytes, 1: activeBool}`.

It contains one or two entries: no more than one `backup-passphrase` envelope
and one `recovery-key` envelope. Each envelope is at most 2048 bytes and the
complete registry at most 8192 bytes. Each represented factor has exactly one
active entry and different factors use different key slots. Artifact IDs are
unique. Entries sort by factor string, generation, then artifact ID. Readers
MUST NOT infer authority from generation, array order, timestamp, or filename.

The public active bit is not a credential-revocation authenticator. Rotation
MUST atomically replace the factor's sole canonical entry and MUST NOT publish
prior and current generations for one factor together. A reader MUST reject a
same-factor multi-generation registry before attempting a credential. An old
offline package copy can remain openable by its old factor.

## 9. Encrypted payloads

### 9.1 Pack segment

A `.pack` file starts with this 48-byte big-endian binary header:

| Offset | Bytes | Value |
|---:|---:|---|
| 0 | 8 | `PHOTPK01` |
| 8 | 2 | segment version `1` |
| 10 | 2 | reserved zero |
| 12 | 16 | segment identifier |
| 28 | 4 | entry count |
| 32 | 8 | entry-area byte count |
| 40 | 8 | trailing index-frame byte count |

Each entry is a 32-byte prefix followed by one sealed frame:
`resourceIdentifier:bstr16`, `ordinal:u64`, `frameByteCount:u64`. The frame uses
the `pack-entry` AAD domain, the resource identifier as artifact identifier,
and the prefix ordinal.

The trailing pack-index frame uses the `pack-index` domain, the segment ID as
artifact ID, and ordinal `18446744073709551615`. Its plaintext is:

```text
map {
  0: "com.photress.backup.pack-index.v1",
  1: segmentIdentifier:bstr16,
  2: entryCount:u64,
  3: entryAreaByteCount:u64,
  4: totalPlaintextByteCount:u64,
  5: array [
       [identifier:bstr16, ordinal:u64, frameOffset:u64,
        frameByteCount:u64, plaintextByteCount:u64,
        plaintextSHA256:bstr32, frameSHA256:bstr32]
     ]
}
```

A pack contains at most 32 entries and 64 MiB plaintext. A resource included in
a pack is at most 16 MiB.

### 9.2 Standalone resource chunk

A `.chunk` file is one sealed frame using the `resource-chunk` AAD domain. Its
artifact ID and ordinal are taken from the authenticated resource-part record.
Resources larger than 16 MiB are split into ordered standalone chunks of at
most 16 MiB each. A complete resource is at most 64 GiB.

## 10. Manifest records

A wrapped payload-key plaintext is `map{0: payload-key-envelope schema, 1:
keyID, 2: scope, 3: boundID, 4: key:bstr32}` and is sealed under the BPMK with
the `payload-key-envelope` domain and ordinal zero. Scope `0` binds a pack key;
scope `1` binds a resource key.

Manifest record kinds and values are frozen:

| Kind | Value | Record schema |
|---|---:|---|
| Payload key | 0 | `com.photress.backup.manifest.payload-key.v1` |
| Item | 1 | `com.photress.backup.manifest.item.v1` |
| Resource | 2 | `com.photress.backup.manifest.resource.v1` |
| Folder | 3 | `com.photress.backup.manifest.folder.v1` |
| Album | 4 | `com.photress.backup.manifest.album.v1` |
| Membership | 5 | `com.photress.backup.manifest.membership.v1` |
| Tombstone | 6 | `com.photress.backup.manifest.tombstone.v1` |

The exact canonical record maps are:

- payload key: `{0:schema, 1:id, 2:scope, 3:boundID, 4:wrappedKeyFrame}`;
- item: `{0:schema, 1:id, 2:mediaKind, 3:displayDate, 4:timezone|null,
  5:dateSource, 6:importDate, 7:filename, 8:favorite, 9:lifecycle,
  10:trashDate|null, 11:sourceType, 12:entitlementCounted, 13:resourceIDs}`;
- resource: `{0:schema, 1:id, 2:itemID, 3:role, 4:UTI, 5:plaintextBytes,
  6:width|null, 7:height|null, 8:durationIEEE754Bits|null,
  9:plaintextSHA256, 10:keyID, 11:parts}`;
- folder: `{0:schema, 1:id, 2:parentID|null, 3:name, 4:manualPosition}`;
- album: `{0:schema, 1:id, 2:parentFolderID|null, 3:name,
  4:coverItemID|null, 5:sortMode, 6:manualOrderEnabled, 7:manualPosition}`;
- membership: `{0:schema, 1:albumID, 2:itemID, 3:manualPosition,
  4:addedAt}`;
- tombstone: `{0:schema, 1:itemID, 2:deletedAt, 3:resourceIDs}`.

A resource part is the fixed array `[ordinal, plaintextOffset, plaintextBytes,
plaintextSHA256, container, artifactID, artifactOrdinal, artifactFileBytes,
artifactFileSHA256]`. Container `0` means pack; `1` means chunk.

Enum values:

- media: photo `0`, video `1`, Live Photo `2`;
- date source: Photos `0`, embedded `1`, file `2`, imported `3`, camera `4`;
- lifecycle: active `0`, trash `1`;
- source: Photos `0`, Files `1`, Share Extension `2`, Private Camera `3`,
  restore `4`;
- resource role: primary `0`, paired video `1`, original `2`, rendered `3`;
- album sort: manual `0`, display date ascending/descending `1`/`2`, import
  date ascending/descending `3`/`4`.

Readers MUST validate unique IDs, references, sort order, resource-part
contiguity, aggregate byte counts/digests, Live Photo role pairing, album and
folder references, acyclic nesting, and a maximum folder depth of 16.

## 11. Authenticated graph

### 11.1 Common artifact reference

`artifactRef` is `[identifier:bstr16, frameByteCount:u64,
frameSHA256:bstr32, plaintextByteCount:u64, plaintextSHA256:bstr32]`.
Every referenced file MUST match both frame properties before decryption and
both plaintext properties after authenticated opening.

`counts` is `[payloadKeys, items, resources, folders, albums, memberships,
tombstones]`.

### 11.2 Manifest section and root

A section reference is `[artifactRef, ordinal, kind, compression, recordCount,
firstSortKey, lastSortKey, decodedByteCount, decodedSHA256]`. Sort keys contain
1–32 bytes: the record's 16-byte primary identifier, or `albumID || itemID` for
a 32-byte membership key.

A manifest section plaintext is:

```text
map {0:schema, 1:id, 2:manifestID, 3:libraryID, 4:ordinal,
     5:kind, 6:compression,
     7:array [[sortKey:bytes, canonicalRecordBytes:bytes], ...]}
```

A manifest root plaintext is:

```text
map {0:schema, 1:id, 2:libraryID, 3:snapshotID, 4:createdAt,
     5:counts, 6:logicalPlaintextBytes, 7:requiredPayloadSetSHA256,
     8:sectionReferences, 9:sectionSetSHA256}
```

Manifest sections use the `manifest` AAD domain and their section ordinal. The
root uses the same domain and ordinal `18446744073709551615`.

### 11.3 Snapshot, commit, and head

Snapshot plaintext:

```text
map {0:schema, 1:id, 2:libraryID, 3:createdAt, 4:manifestArtifactRef,
     5:sectionSetSHA256, 6:requiredPayloadSetSHA256, 7:counts,
     8:logicalPlaintextBytes, 9:encryptedPayloadBytes}
```

Commit plaintext:

```text
map {0:schema, 1:id, 2:libraryID, 3:parentArtifactRef|null,
     4:snapshotArtifactRef, 5:manifestID, 6:writerDeviceID,
     7:writerEpoch, 8:generation, 9:createdAt,
     10:requiredPayloadSetSHA256, 11:acknowledgedAlternateTipRefs}
```

Head plaintext:

```text
map {0:schema, 1:id, 2:libraryID, 3:commitArtifactRef, 4:createdAt}
```

Snapshot, commit, and head use their matching AAD domains with ordinal zero.
Generation zero MUST have no parent; every later generation MUST have exactly
one authenticated parent.

The section-set digest is SHA-256 over the canonical CBOR array of the ordered
section references. The required-payload-set digest is SHA-256 over this exact
byte sequence:

```text
UTF8("photress/backup/required-payload-set/v1") || 0x00 ||
BE64(artifactCount) ||
for each required artifact sorted lexicographically by artifactIdentifier:
  artifactIdentifier:bstr16 ||
  BE64(containerValue) ||
  BE64(artifactFileByteCount) ||
  artifactFileSHA256:bstr32 ||
  payloadKeyIdentifier:bstr16
```

It binds the root, snapshot, commit, and the exact payload set required for
recovery.

## 12. Branch and commit semantics

Payload and graph artifacts are immutable. A writer publishes payloads, then
manifest sections/root, then snapshot, commit, and finally a head candidate.
Only a fully authenticated head chain represents a committed snapshot.

`advisory/` markers are coordination hints, never locks or authority. Two
authenticated commits with the same parent are sibling branches. A reader or
writer MUST NOT choose a sibling by highest generation, writer epoch, wall
clock, directory order, or modification time. It enters explicit conflict
handling, preserves all valid tips, and suspends garbage collection until the
fork is resolved. Acknowledged alternate tips are explicit authenticated commit
references; they are not inferred.

`Destination committed`, provider upload confirmation, cryptographic
verification, restore testing, and redundancy are separate external facts.
The package format by itself proves none of the provider/upload facts.

## 13. Required V1 limits

| Limit | Value |
|---|---:|
| Identifier / SHA-256 | 16 / 32 bytes |
| Pack resources / plaintext | 32 / 64 MiB |
| Packed resource / standalone chunk | 16 MiB / 16 MiB |
| Pack index plaintext | 64 KiB |
| Manifest section / root plaintext | 8 MiB / 2 MiB |
| Graph descriptor plaintext | 64 KiB |
| Manifest sections / records per section | 4096 / 4096 |
| Folder depth | 16 |
| Resource plaintext | 64 GiB |
| Payload-key envelope plaintext | 256 bytes |

Implementations also MUST apply bounded collection, string, CBOR-depth, total
record, total logical-byte, and reference-count limits before allocation. A
failure at any layer invalidates the affected candidate; it MUST NOT produce a
partial successful export.

## 14. Compatibility policy

V1 identifiers, integer keys, domains, KDF bindings, field meanings, and
fixtures are immutable. Adding optional fields to an existing exact-field V1
map is incompatible and therefore requires a new explicitly versioned schema.

- Incompatible format changes require a new major format identifier/version.
- Writers MUST never rewrite a newer or unknown package as V1.
- A newer writer MUST preserve the previous authenticated snapshot and warn
  before an explicit migration.
- The app reads at least the two previous production major versions or ships an
  offline migration/recovery path for them.
- The public recovery utility reads every supported production version and is
  retained with its matching fixtures.
- Readers fail closed on unknown versions; they never perform a best-effort
  downgrade.

Prototype fixture `PHOTRESS-DRAFT` / version `0` is test history, not a public
production format and MUST NOT be emitted by a production writer.

## 15. Frozen synthetic interoperability corpus

The canonical index is
`Packages/PhotressCore/Tests/PhotressGoldenFixtureTests/Fixtures/recovery-fixtures.json`.
Each entry includes a SHA-256 over the exact fixture file bytes. The V1 corpus
contains only synthetic, rights-safe material:

| Fixture | SHA-256 |
|---|---|
| `draft-header-v0.json` | `aff363711e7025e5776ca2f569fe6ed23107b1d6f7044ce5aef6228e7cb1aca5` |
| `backup-format-v1.json` | `c6847a7c9377b254acc4fa9ac2e49bbd8cfb8c3914f3214f869e191b5d1b42d9` |
| `backup-key-envelopes-v1.json` | `fb60ea70444322b9e4db03ca785281e4ea15482fcaa6e8538623740e9e616302` |
| `backup-payload-v1.json` | `f47b1c40b1b78714e9a678955321b471ef2057c48c7622c0b58753078100eed9` |
| `backup-graph-v1.json` | `306d935bef9146654769640cae5b2fe9a6e856a7adc21990981b8426a8cc5c89` |

The index verifier checks the frozen file digest before decoding and then runs
the same strict format, envelope, payload, graph, and recovery readers used by
the application and reference recovery utility. New format versions MUST add
fixtures; prior fixture bytes and their readers remain preserved.

## 16. Reference implementation

The normative implementation is split across the project-owned Swift package
modules `PhotressCore`, `PhotressCryptoPrimitives`,
`PhotressPortableKeyEnvelope`, and `PhotressRecoveryCore`. The offline
`photress-recovery` executable is the reference reader. Its `inspect` and
`export` commands accept credentials only through a hidden terminal prompt or
redirected standard input, never as an argument or environment option. Inspect
returns a full SHA-256-derived snapshot `selectionCode` instead of a raw commit
identifier; export re-inspects the package and requires exactly one matching
authenticated commit before calling the shared exporter. This specification
and the frozen fixture corpus take precedence over informal examples; any
mismatch between this document, fixtures, and implementation is a
release-blocking compatibility defect.
