1056 lines
50 KiB
C++
1056 lines
50 KiB
C++
#include "sppm.hpp"
|
|
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <limits>
|
|
#include <random>
|
|
#include <system_error>
|
|
|
|
#ifdef __SWITCH__
|
|
#include <switch.h>
|
|
#endif
|
|
|
|
#ifdef _WIN32
|
|
#define NOMINMAX
|
|
#include <windows.h>
|
|
#endif
|
|
|
|
extern "C" {
|
|
int crypto_sign_ed25519_tweet(unsigned char*, unsigned long long*, const unsigned char*, unsigned long long, const unsigned char*);
|
|
int crypto_sign_ed25519_tweet_open(unsigned char*, unsigned long long*, const unsigned char*, unsigned long long, const unsigned char*);
|
|
int crypto_sign_ed25519_tweet_keypair(unsigned char*, unsigned char*);
|
|
int crypto_secretbox_xsalsa20poly1305_tweet(unsigned char*, const unsigned char*, unsigned long long, const unsigned char*, const unsigned char*);
|
|
int crypto_secretbox_xsalsa20poly1305_tweet_open(unsigned char*, const unsigned char*, unsigned long long, const unsigned char*, const unsigned char*);
|
|
|
|
void randombytes(unsigned char* out, unsigned long long length) {
|
|
#ifdef __SWITCH__
|
|
if (R_FAILED(csrngGetRandomBytes(out, static_cast<std::size_t>(length)))) std::abort();
|
|
#else
|
|
std::random_device random;
|
|
for (unsigned long long i = 0; i < length; ++i) out[i] = static_cast<unsigned char>(random());
|
|
#endif
|
|
}
|
|
}
|
|
|
|
namespace sppm {
|
|
namespace {
|
|
|
|
constexpr std::uint16_t kVersion = 3;
|
|
constexpr std::uint16_t kLegacyVersion = 1;
|
|
constexpr std::uint16_t kHeaderBytes = 36;
|
|
constexpr std::uint64_t kMaxFileBytes = 512ULL * 1024ULL * 1024ULL;
|
|
constexpr std::size_t kMaxAudioSamples = 32ULL * 1024ULL * 1024ULL;
|
|
constexpr std::size_t kMaxLayerRleBytes = kPixelsPerLayer * 2;
|
|
constexpr std::size_t kSignatureBytes = 36;
|
|
constexpr std::size_t kPublicSignatureBytes = 4 + 3 * (32 + 16) + 64;
|
|
constexpr char kNullId[] = "0000000000000000";
|
|
|
|
std::string upperId(std::string_view id) {
|
|
std::string out(id);
|
|
for (char& c : out) {
|
|
if (c >= 'a' && c <= 'f') c = static_cast<char>(c - 'a' + 'A');
|
|
}
|
|
return out;
|
|
}
|
|
|
|
bool sameId(std::string_view a, std::string_view b) {
|
|
return upperId(a) == upperId(b);
|
|
}
|
|
|
|
bool validUtf8(std::string_view text) {
|
|
for (std::size_t i = 0; i < text.size();) {
|
|
const auto first = static_cast<std::uint8_t>(text[i]);
|
|
if (first < 0x80) { ++i; continue; }
|
|
std::size_t length;
|
|
if (first >= 0xC2 && first <= 0xDF) length = 2;
|
|
else if (first >= 0xE0 && first <= 0xEF) length = 3;
|
|
else if (first >= 0xF0 && first <= 0xF4) length = 4;
|
|
else return false;
|
|
if (length > text.size() - i) return false;
|
|
const auto second = static_cast<std::uint8_t>(text[i + 1]);
|
|
if (second < 0x80 || second > 0xBF ||
|
|
(first == 0xE0 && second < 0xA0) ||
|
|
(first == 0xED && second > 0x9F) ||
|
|
(first == 0xF0 && second < 0x90) ||
|
|
(first == 0xF4 && second > 0x8F)) return false;
|
|
for (std::size_t j = 2; j < length; ++j) {
|
|
const auto continuation = static_cast<std::uint8_t>(text[i + j]);
|
|
if (continuation < 0x80 || continuation > 0xBF) return false;
|
|
}
|
|
i += length;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
std::uint32_t crc32(const std::uint8_t* bytes, std::size_t length) {
|
|
static const auto table = [] {
|
|
std::array<std::uint32_t, 256> entries{};
|
|
for (std::uint32_t i = 0; i < entries.size(); ++i) {
|
|
std::uint32_t c = i;
|
|
for (int j = 0; j < 8; ++j) c = (c >> 1) ^ ((c & 1) ? 0xEDB88320U : 0U);
|
|
entries[i] = c;
|
|
}
|
|
return entries;
|
|
}();
|
|
std::uint32_t c = 0xFFFFFFFFU;
|
|
for (std::size_t i = 0; i < length; ++i) c = table[(c ^ bytes[i]) & 0xFFU] ^ (c >> 8);
|
|
return c ^ 0xFFFFFFFFU;
|
|
}
|
|
|
|
class Writer {
|
|
public:
|
|
std::vector<std::uint8_t> bytes;
|
|
void u8(std::uint8_t v) { bytes.push_back(v); }
|
|
void u16(std::uint16_t v) { u8(static_cast<std::uint8_t>(v)); u8(static_cast<std::uint8_t>(v >> 8)); }
|
|
void u32(std::uint32_t v) { for (int i = 0; i < 4; ++i) u8(static_cast<std::uint8_t>(v >> (i * 8))); }
|
|
void u64(std::uint64_t v) { for (int i = 0; i < 8; ++i) u8(static_cast<std::uint8_t>(v >> (i * 8))); }
|
|
void raw(const std::uint8_t* p, std::size_t n) { if (n) bytes.insert(bytes.end(), p, p + n); }
|
|
void text(std::string_view value) { u16(static_cast<std::uint16_t>(value.size())); raw(reinterpret_cast<const std::uint8_t*>(value.data()), value.size()); }
|
|
void id(std::string_view value) {
|
|
const std::string normal = upperId(value.empty() ? std::string_view(kNullId) : value);
|
|
raw(reinterpret_cast<const std::uint8_t*>(normal.data()), normal.size());
|
|
}
|
|
void setU32(std::size_t at, std::uint32_t v) { for (int i = 0; i < 4; ++i) bytes[at + i] = static_cast<std::uint8_t>(v >> (i * 8)); }
|
|
void setU64(std::size_t at, std::uint64_t v) { for (int i = 0; i < 8; ++i) bytes[at + i] = static_cast<std::uint8_t>(v >> (i * 8)); }
|
|
};
|
|
|
|
class Reader {
|
|
public:
|
|
explicit Reader(const std::vector<std::uint8_t>& b) : bytes(b) {}
|
|
const std::vector<std::uint8_t>& bytes;
|
|
std::size_t at = 0;
|
|
bool take(std::size_t n, const std::uint8_t*& p) {
|
|
if (n > bytes.size() - at) return false;
|
|
p = bytes.data() + at;
|
|
at += n;
|
|
return true;
|
|
}
|
|
bool u8(std::uint8_t& v) { const std::uint8_t* p; if (!take(1, p)) return false; v = *p; return true; }
|
|
bool u16(std::uint16_t& v) { const std::uint8_t* p; if (!take(2, p)) return false; v = static_cast<std::uint16_t>(p[0] | (p[1] << 8)); return true; }
|
|
bool u32(std::uint32_t& v) { const std::uint8_t* p; if (!take(4, p)) return false; v = 0; for (int i = 0; i < 4; ++i) v |= static_cast<std::uint32_t>(p[i]) << (i * 8); return true; }
|
|
bool u64(std::uint64_t& v) { const std::uint8_t* p; if (!take(8, p)) return false; v = 0; for (int i = 0; i < 8; ++i) v |= static_cast<std::uint64_t>(p[i]) << (i * 8); return true; }
|
|
bool text(std::string& out, std::size_t maxLength) {
|
|
std::uint16_t n;
|
|
const std::uint8_t* p;
|
|
if (!u16(n) || n > maxLength || !take(n, p)) return false;
|
|
out.assign(reinterpret_cast<const char*>(p), n);
|
|
return out.find('\0') == std::string::npos && validUtf8(out);
|
|
}
|
|
bool id(std::string& out, bool nullable) {
|
|
const std::uint8_t* p;
|
|
if (!take(16, p)) return false;
|
|
out.assign(reinterpret_cast<const char*>(p), 16);
|
|
if (nullable && out == kNullId) { out.clear(); return true; }
|
|
if (!isValidFsid(out) || out == kNullId) return false;
|
|
out = upperId(out);
|
|
return true;
|
|
}
|
|
};
|
|
|
|
std::vector<std::uint8_t> encodeLayer(const std::vector<std::uint8_t>& pixels) {
|
|
std::vector<std::uint8_t> out;
|
|
out.reserve(pixels.size() / 2);
|
|
std::size_t at = 0;
|
|
while (at < pixels.size()) {
|
|
std::size_t run = 1;
|
|
while (run < 128 && at + run < pixels.size() && pixels[at + run] == pixels[at]) ++run;
|
|
if (run >= 3) {
|
|
out.push_back(static_cast<std::uint8_t>(0x80U | (run - 1)));
|
|
out.push_back(pixels[at]);
|
|
at += run;
|
|
continue;
|
|
}
|
|
const std::size_t start = at;
|
|
at += run;
|
|
while (at - start < 128 && at < pixels.size()) {
|
|
run = 1;
|
|
while (run < 3 && at + run < pixels.size() && pixels[at + run] == pixels[at]) ++run;
|
|
if (run >= 3 || at + run - start > 128) break;
|
|
at += run;
|
|
}
|
|
out.push_back(static_cast<std::uint8_t>(at - start - 1));
|
|
out.insert(out.end(), pixels.begin() + static_cast<std::ptrdiff_t>(start),
|
|
pixels.begin() + static_cast<std::ptrdiff_t>(at));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
bool decodeLayer(const std::uint8_t* p, std::size_t n, std::vector<std::uint8_t>& out) {
|
|
out.clear();
|
|
out.reserve(kPixelsPerLayer);
|
|
std::size_t at = 0;
|
|
while (at < n) {
|
|
const std::uint8_t command = p[at++];
|
|
const std::size_t count = static_cast<std::size_t>(command & 0x7FU) + 1;
|
|
if (count > kPixelsPerLayer - out.size()) return false;
|
|
if (command & 0x80U) {
|
|
if (at == n) return false;
|
|
out.insert(out.end(), count, p[at++]);
|
|
} else {
|
|
if (count > n - at) return false;
|
|
out.insert(out.end(), p + at, p + at + count);
|
|
at += count;
|
|
}
|
|
}
|
|
return out.size() == kPixelsPerLayer;
|
|
}
|
|
|
|
std::uint32_t rightRotate(std::uint32_t x, unsigned n) { return (x >> n) | (x << (32 - n)); }
|
|
|
|
class Sha256 {
|
|
public:
|
|
void update(const std::uint8_t* p, std::size_t n) {
|
|
total += n;
|
|
while (n) {
|
|
const std::size_t step = std::min(n, block.size() - used);
|
|
std::memcpy(block.data() + used, p, step);
|
|
used += step;
|
|
p += step;
|
|
n -= step;
|
|
if (used == block.size()) { transform(); used = 0; }
|
|
}
|
|
}
|
|
std::array<std::uint8_t, 32> finish() {
|
|
const std::uint64_t bitLength = static_cast<std::uint64_t>(total) * 8;
|
|
const std::uint8_t marker = 0x80;
|
|
update(&marker, 1);
|
|
const std::uint8_t zero = 0;
|
|
while (used != 56) update(&zero, 1);
|
|
std::uint8_t length[8];
|
|
for (int i = 0; i < 8; ++i) length[i] = static_cast<std::uint8_t>(bitLength >> ((7 - i) * 8));
|
|
update(length, 8);
|
|
std::array<std::uint8_t, 32> result{};
|
|
for (int i = 0; i < 8; ++i) {
|
|
result[i * 4] = static_cast<std::uint8_t>(state[i] >> 24);
|
|
result[i * 4 + 1] = static_cast<std::uint8_t>(state[i] >> 16);
|
|
result[i * 4 + 2] = static_cast<std::uint8_t>(state[i] >> 8);
|
|
result[i * 4 + 3] = static_cast<std::uint8_t>(state[i]);
|
|
}
|
|
return result;
|
|
}
|
|
private:
|
|
static constexpr std::uint32_t K[64] = {
|
|
0x428a2f98U,0x71374491U,0xb5c0fbcfU,0xe9b5dba5U,0x3956c25bU,0x59f111f1U,0x923f82a4U,0xab1c5ed5U,
|
|
0xd807aa98U,0x12835b01U,0x243185beU,0x550c7dc3U,0x72be5d74U,0x80deb1feU,0x9bdc06a7U,0xc19bf174U,
|
|
0xe49b69c1U,0xefbe4786U,0x0fc19dc6U,0x240ca1ccU,0x2de92c6fU,0x4a7484aaU,0x5cb0a9dcU,0x76f988daU,
|
|
0x983e5152U,0xa831c66dU,0xb00327c8U,0xbf597fc7U,0xc6e00bf3U,0xd5a79147U,0x06ca6351U,0x14292967U,
|
|
0x27b70a85U,0x2e1b2138U,0x4d2c6dfcU,0x53380d13U,0x650a7354U,0x766a0abbU,0x81c2c92eU,0x92722c85U,
|
|
0xa2bfe8a1U,0xa81a664bU,0xc24b8b70U,0xc76c51a3U,0xd192e819U,0xd6990624U,0xf40e3585U,0x106aa070U,
|
|
0x19a4c116U,0x1e376c08U,0x2748774cU,0x34b0bcb5U,0x391c0cb3U,0x4ed8aa4aU,0x5b9cca4fU,0x682e6ff3U,
|
|
0x748f82eeU,0x78a5636fU,0x84c87814U,0x8cc70208U,0x90befffaU,0xa4506cebU,0xbef9a3f7U,0xc67178f2U
|
|
};
|
|
std::array<std::uint32_t, 8> state{0x6a09e667U,0xbb67ae85U,0x3c6ef372U,0xa54ff53aU,0x510e527fU,0x9b05688cU,0x1f83d9abU,0x5be0cd19U};
|
|
std::array<std::uint8_t, 64> block{};
|
|
std::size_t used = 0;
|
|
std::size_t total = 0;
|
|
void transform() {
|
|
std::uint32_t w[64];
|
|
for (int i = 0; i < 16; ++i) {
|
|
const int j = i * 4;
|
|
w[i] = (static_cast<std::uint32_t>(block[j]) << 24) | (static_cast<std::uint32_t>(block[j+1]) << 16) |
|
|
(static_cast<std::uint32_t>(block[j+2]) << 8) | block[j+3];
|
|
}
|
|
for (int i = 16; i < 64; ++i) {
|
|
const std::uint32_t s0 = rightRotate(w[i-15], 7) ^ rightRotate(w[i-15], 18) ^ (w[i-15] >> 3);
|
|
const std::uint32_t s1 = rightRotate(w[i-2], 17) ^ rightRotate(w[i-2], 19) ^ (w[i-2] >> 10);
|
|
w[i] = w[i-16] + s0 + w[i-7] + s1;
|
|
}
|
|
std::uint32_t a=state[0], b=state[1], c=state[2], d=state[3], e=state[4], f=state[5], g=state[6], h=state[7];
|
|
for (int i = 0; i < 64; ++i) {
|
|
const std::uint32_t s1 = rightRotate(e, 6) ^ rightRotate(e, 11) ^ rightRotate(e, 25);
|
|
const std::uint32_t choose = (e & f) ^ (~e & g);
|
|
const std::uint32_t t1 = h + s1 + choose + K[i] + w[i];
|
|
const std::uint32_t s0 = rightRotate(a, 2) ^ rightRotate(a, 13) ^ rightRotate(a, 22);
|
|
const std::uint32_t majority = (a & b) ^ (a & c) ^ (b & c);
|
|
const std::uint32_t t2 = s0 + majority;
|
|
h=g; g=f; f=e; e=d+t1; d=c; c=b; b=a; a=t1+t2;
|
|
}
|
|
state[0]+=a; state[1]+=b; state[2]+=c; state[3]+=d;
|
|
state[4]+=e; state[5]+=f; state[6]+=g; state[7]+=h;
|
|
}
|
|
};
|
|
|
|
std::array<std::uint8_t, 32> hmacSha256(const std::array<std::uint8_t, 32>& secret,
|
|
const std::uint8_t* message, std::size_t length) {
|
|
std::array<std::uint8_t, 64> innerKey{}, outerKey{};
|
|
for (std::size_t i = 0; i < 64; ++i) {
|
|
const std::uint8_t key = i < secret.size() ? secret[i] : 0;
|
|
innerKey[i] = key ^ 0x36U;
|
|
outerKey[i] = key ^ 0x5cU;
|
|
}
|
|
Sha256 inner;
|
|
inner.update(innerKey.data(), innerKey.size());
|
|
inner.update(message, length);
|
|
const auto middle = inner.finish();
|
|
Sha256 outer;
|
|
outer.update(outerKey.data(), outerKey.size());
|
|
outer.update(middle.data(), middle.size());
|
|
return outer.finish();
|
|
}
|
|
|
|
bool commitBytes(const std::string& path, const std::vector<std::uint8_t>& bytes,
|
|
std::string& error) {
|
|
if (path.empty()) { error = "empty save path"; return false; }
|
|
static std::atomic<std::uint64_t> sequence{0};
|
|
const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count();
|
|
const std::uint64_t serial = sequence.fetch_add(1);
|
|
std::filesystem::path target(path);
|
|
std::filesystem::path temporary = target;
|
|
temporary += ".tmp-" + std::to_string(stamp) + "-" + std::to_string(serial);
|
|
{
|
|
std::ofstream output(temporary, std::ios::binary | std::ios::trunc);
|
|
if (!output) { error = "could not create temporary save file"; return false; }
|
|
output.write(reinterpret_cast<const char*>(bytes.data()), static_cast<std::streamsize>(bytes.size()));
|
|
output.flush();
|
|
if (!output) {
|
|
output.close();
|
|
std::error_code ignored;
|
|
std::filesystem::remove(temporary, ignored);
|
|
error = "could not write complete temporary save file";
|
|
return false;
|
|
}
|
|
}
|
|
#ifdef _WIN32
|
|
if (!MoveFileExW(temporary.c_str(), target.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) {
|
|
std::error_code ignored;
|
|
std::filesystem::remove(temporary, ignored);
|
|
error = "could not replace save file";
|
|
return false;
|
|
}
|
|
#else
|
|
std::error_code ec;
|
|
std::filesystem::rename(temporary, target, ec);
|
|
if (ec) {
|
|
// Some SD/FAT rename implementations refuse to replace an existing
|
|
// file. Keep the old note recoverable while installing the new one.
|
|
std::error_code existsError;
|
|
const bool targetExists = std::filesystem::exists(target, existsError);
|
|
if (!targetExists || existsError) {
|
|
std::error_code ignored;
|
|
std::filesystem::remove(temporary, ignored);
|
|
error = "could not replace save file: " + ec.message();
|
|
return false;
|
|
}
|
|
std::filesystem::path backup = target;
|
|
backup += ".bak-" + std::to_string(stamp) + "-" + std::to_string(serial);
|
|
std::error_code backupError;
|
|
std::filesystem::rename(target, backup, backupError);
|
|
if (backupError) {
|
|
std::error_code ignored;
|
|
std::filesystem::remove(temporary, ignored);
|
|
error = "could not preserve previous save file: " + backupError.message();
|
|
return false;
|
|
}
|
|
ec.clear();
|
|
std::filesystem::rename(temporary, target, ec);
|
|
if (ec) {
|
|
std::error_code restoreError;
|
|
std::filesystem::rename(backup, target, restoreError);
|
|
std::error_code ignored;
|
|
std::filesystem::remove(temporary, ignored);
|
|
error = "could not install new save file: " + ec.message();
|
|
if (restoreError) error += "; previous save preserved at " + backup.string();
|
|
return false;
|
|
}
|
|
std::error_code ignored;
|
|
std::filesystem::remove(backup, ignored);
|
|
}
|
|
#endif
|
|
error.clear();
|
|
return true;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
Palette defaultPalette() {
|
|
Palette palette{};
|
|
palette[0] = {0, 0, 0, 0};
|
|
constexpr std::array<Color, 15> initial = {{{255,255,255,255}, {0,0,0,255}, {255,52,63,255},
|
|
{255,119,28,255}, {255,221,0,255}, {239,68,133,255}, {0,172,102,255},
|
|
{0,214,231,255}, {21,82,151,255}, {132,66,183,255}, {132,66,183,255},
|
|
{239,68,133,255}, {144,94,62,255}, {115,122,131,255}, {210,219,230,255}}};
|
|
std::copy(initial.begin(), initial.end(), palette.begin() + 1);
|
|
for (std::size_t i = 16; i < palette.size(); ++i) {
|
|
const std::uint8_t shade = static_cast<std::uint8_t>((i - 16) * 255 / (palette.size() - 17));
|
|
palette[i] = {shade, shade, shade, 255};
|
|
}
|
|
return palette;
|
|
}
|
|
|
|
Frame::Frame() {
|
|
for (auto& layer : layers) layer.assign(kPixelsPerLayer, 0);
|
|
}
|
|
|
|
bool isValidFsid(std::string_view fsid) {
|
|
if (fsid.size() != 16) return false;
|
|
for (char c : fsid) {
|
|
if (!((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'))) return false;
|
|
}
|
|
return fsid != kNullId;
|
|
}
|
|
|
|
std::string deriveFsid(const std::array<std::uint8_t, 32>& secret,
|
|
const std::array<std::uint8_t, 16>& nonce,
|
|
std::string_view domain) {
|
|
constexpr char prefix[] = "FlipnoteNX-FSID-v1";
|
|
std::vector<std::uint8_t> message;
|
|
message.insert(message.end(), prefix, prefix + sizeof(prefix)); // includes separator NUL
|
|
message.insert(message.end(), domain.begin(), domain.end());
|
|
message.push_back(0);
|
|
message.insert(message.end(), nonce.begin(), nonce.end());
|
|
const auto digest = hmacSha256(secret, message.data(), message.size());
|
|
constexpr char hex[] = "0123456789ABCDEF";
|
|
std::string result(16, '0');
|
|
for (int i = 0; i < 8; ++i) {
|
|
result[i * 2] = hex[digest[i] >> 4];
|
|
result[i * 2 + 1] = hex[digest[i] & 15];
|
|
}
|
|
return result;
|
|
}
|
|
|
|
bool hasPublicIdentity(const PublicIdentity& identity) {
|
|
return std::any_of(identity.key.begin(), identity.key.end(), [](std::uint8_t value) { return value != 0; }) &&
|
|
std::any_of(identity.deviceTag.begin(), identity.deviceTag.end(), [](std::uint8_t value) { return value != 0; });
|
|
}
|
|
|
|
std::array<std::uint8_t, 16> deriveDeviceTag(const std::vector<std::uint8_t>& deviceMaterial) {
|
|
constexpr char domain[] = "FlipnoteNX-device-tag-v1";
|
|
Sha256 hash;
|
|
hash.update(reinterpret_cast<const std::uint8_t*>(domain), sizeof(domain));
|
|
if (!deviceMaterial.empty()) hash.update(deviceMaterial.data(), deviceMaterial.size());
|
|
const auto digest = hash.finish();
|
|
std::array<std::uint8_t, 16> tag{};
|
|
std::copy_n(digest.begin(), tag.size(), tag.begin());
|
|
return tag;
|
|
}
|
|
|
|
std::array<std::uint8_t, 32> deriveDeviceWrapKey(const std::vector<std::uint8_t>& deviceMaterial,
|
|
const std::array<std::uint8_t, 16>& salt) {
|
|
constexpr char domain[] = "FlipnoteNX-identity-wrap-v1";
|
|
Sha256 materialHash;
|
|
materialHash.update(reinterpret_cast<const std::uint8_t*>(domain), sizeof(domain));
|
|
if (!deviceMaterial.empty()) materialHash.update(deviceMaterial.data(), deviceMaterial.size());
|
|
const auto key = materialHash.finish();
|
|
std::vector<std::uint8_t> context(domain, domain + sizeof(domain));
|
|
context.insert(context.end(), salt.begin(), salt.end());
|
|
return hmacSha256(key, context.data(), context.size());
|
|
}
|
|
|
|
std::string deriveFsid(const PublicIdentity& identity) {
|
|
if (!hasPublicIdentity(identity)) return {};
|
|
constexpr char domain[] = "FlipnoteNX-public-fsid-v2";
|
|
Sha256 hash;
|
|
hash.update(reinterpret_cast<const std::uint8_t*>(domain), sizeof(domain));
|
|
hash.update(identity.key.data(), identity.key.size());
|
|
hash.update(identity.deviceTag.data(), identity.deviceTag.size());
|
|
const auto digest = hash.finish();
|
|
constexpr char hex[] = "0123456789ABCDEF";
|
|
std::string result(16, '0');
|
|
for (int i = 0; i < 8; ++i) {
|
|
result[i * 2] = hex[digest[static_cast<std::size_t>(i)] >> 4];
|
|
result[i * 2 + 1] = hex[digest[static_cast<std::size_t>(i)] & 15];
|
|
}
|
|
return result;
|
|
}
|
|
|
|
std::string identityFingerprint(const PublicIdentity& identity) {
|
|
if (!hasPublicIdentity(identity)) return {};
|
|
constexpr char domain[] = "FlipnoteNX-gallery-key-v2";
|
|
Sha256 hash;
|
|
hash.update(reinterpret_cast<const std::uint8_t*>(domain), sizeof(domain));
|
|
hash.update(identity.key.data(), identity.key.size());
|
|
const auto digest = hash.finish();
|
|
constexpr char hex[] = "0123456789abcdef";
|
|
std::string result;
|
|
result.reserve(digest.size() * 2);
|
|
for (std::uint8_t byte : digest) {
|
|
result.push_back(hex[byte >> 4]);
|
|
result.push_back(hex[byte & 15]);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
bool createSigningIdentity(const std::array<std::uint8_t, 16>& deviceTag,
|
|
SigningIdentity& identity, std::string& error) {
|
|
if (!std::any_of(deviceTag.begin(), deviceTag.end(), [](std::uint8_t value) { return value != 0; }) ||
|
|
crypto_sign_ed25519_tweet_keypair(identity.publicIdentity.key.data(), identity.secretKey.data()) != 0) {
|
|
error = "could not create Ed25519 signing identity";
|
|
return false;
|
|
}
|
|
identity.publicIdentity.deviceTag = deviceTag;
|
|
error.clear();
|
|
return true;
|
|
}
|
|
|
|
bool sealSigningSecret(const SigningIdentity& identity,
|
|
const std::array<std::uint8_t, 32>& wrapKey,
|
|
const std::array<std::uint8_t, 24>& nonce,
|
|
std::array<std::uint8_t, 128>& sealed, std::string& error) {
|
|
std::array<std::uint8_t, 128> input{};
|
|
std::copy(identity.secretKey.begin(), identity.secretKey.end(), input.begin() + 32);
|
|
std::copy(identity.legacyHmacKey.begin(), identity.legacyHmacKey.end(), input.begin() + 96);
|
|
if (crypto_secretbox_xsalsa20poly1305_tweet(sealed.data(), input.data(), input.size(), nonce.data(), wrapKey.data()) != 0) {
|
|
error = "could not encrypt signing key";
|
|
return false;
|
|
}
|
|
error.clear();
|
|
return true;
|
|
}
|
|
|
|
bool unsealSigningSecret(const std::array<std::uint8_t, 128>& sealed,
|
|
const std::array<std::uint8_t, 32>& wrapKey,
|
|
const std::array<std::uint8_t, 24>& nonce,
|
|
SigningIdentity& identity, std::string& error) {
|
|
std::array<std::uint8_t, 128> output{};
|
|
if (crypto_secretbox_xsalsa20poly1305_tweet_open(output.data(), sealed.data(), sealed.size(), nonce.data(), wrapKey.data()) != 0 ||
|
|
std::any_of(output.begin(), output.begin() + 32, [](std::uint8_t value) { return value != 0; })) {
|
|
error = "identity key cannot be opened on this console";
|
|
return false;
|
|
}
|
|
std::copy(output.begin() + 32, output.begin() + 96, identity.secretKey.begin());
|
|
std::copy(output.begin() + 96, output.end(), identity.legacyHmacKey.begin());
|
|
error.clear();
|
|
return true;
|
|
}
|
|
|
|
double frameRate(const Metadata& metadata) {
|
|
constexpr std::array<double, 8> rates{{0.4985, 1.0, 2.0, 4.0, 5.9972, 11.986, 19.9459, 29.9275}};
|
|
if (metadata.speed >= 1 && metadata.speed <= rates.size()) return rates[metadata.speed - 1];
|
|
return std::max(1, static_cast<int>(metadata.fps));
|
|
}
|
|
|
|
bool validate(const Document& document, std::string& error) {
|
|
const Metadata& m = document.metadata;
|
|
if (document.frames.empty() || document.frames.size() > kMaxFrames) {
|
|
error = "frame count must be between 1 and 9999"; return false;
|
|
}
|
|
if (document.audioTracks.size() > kMaxAudioTracks) {
|
|
error = "too many audio tracks"; return false;
|
|
}
|
|
if (m.title.size() > 256 || m.description.size() > 4096 ||
|
|
m.authorName.size() > 128 || m.originalAuthorName.size() > 128 ||
|
|
m.lastEditorName.size() > 128) {
|
|
error = "metadata text is too long"; return false;
|
|
}
|
|
for (const std::string* text : {&m.title, &m.description, &m.authorName,
|
|
&m.originalAuthorName, &m.lastEditorName}) {
|
|
if (text->find('\0') != std::string::npos || !validUtf8(*text)) {
|
|
error = "metadata text is not valid UTF-8"; return false;
|
|
}
|
|
}
|
|
if (!isValidFsid(m.authorFsid) || !isValidFsid(m.originalAuthorFsid) ||
|
|
!isValidFsid(m.lastEditorFsid) || !isValidFsid(m.currentId) ||
|
|
!isValidFsid(m.rootId) || (!m.parentId.empty() && !isValidFsid(m.parentId))) {
|
|
error = "one or more IDs are not 16 hexadecimal characters"; return false;
|
|
}
|
|
const auto matchesIdentity = [](const PublicIdentity& identity, const std::string& fsid) {
|
|
return !hasPublicIdentity(identity) || sameId(deriveFsid(identity), fsid);
|
|
};
|
|
if (!matchesIdentity(m.authorIdentity, m.authorFsid) ||
|
|
!matchesIdentity(m.originalAuthorIdentity, m.originalAuthorFsid) ||
|
|
!matchesIdentity(m.lastEditorIdentity, m.lastEditorFsid)) {
|
|
error = "creator FSID does not match its public identity"; return false;
|
|
}
|
|
if (m.parentId.empty()) {
|
|
if (!sameId(m.currentId, m.rootId) || !sameId(m.authorFsid, m.originalAuthorFsid)) {
|
|
error = "an original Switchnote must have its own root ID and original author"; return false;
|
|
}
|
|
} else if (sameId(m.parentId, m.currentId) || sameId(m.rootId, m.currentId)) {
|
|
error = "a spin-off must have a new ID distinct from its parent and root"; return false;
|
|
}
|
|
if (m.fps < 1 || m.fps > 30 || m.speed > 8 || m.coverFrame >= document.frames.size() ||
|
|
m.createdUnixSeconds > m.modifiedUnixSeconds) {
|
|
error = "invalid playback or date metadata"; return false;
|
|
}
|
|
if (document.palette[0].a != 0) {
|
|
error = "palette index 0 must be transparent"; return false;
|
|
}
|
|
for (const Frame& frame : document.frames) {
|
|
for (const auto& layer : frame.layers) {
|
|
if (layer.size() != kPixelsPerLayer) {
|
|
error = "a layer has the wrong pixel count"; return false;
|
|
}
|
|
}
|
|
}
|
|
std::size_t audioSamples = 0;
|
|
for (std::size_t i = 0; i < document.audioTracks.size(); ++i) {
|
|
const AudioTrack& track = document.audioTracks[i];
|
|
if (track.name.size() > 64 || track.name.find('\0') != std::string::npos ||
|
|
!validUtf8(track.name) ||
|
|
track.sampleRate < 8000 || track.sampleRate > 48000 ||
|
|
(i == 0 && track.referenceFps != 0 && (track.referenceFps < 1 || track.referenceFps > 30)) ||
|
|
(i == 0 && track.referenceSpeed > 8) ||
|
|
(i == 0 && track.referenceSpeed != 0 && track.referenceFps != 0) ||
|
|
(i > 0 && (track.referenceFps != 0 || track.referenceSpeed != 0)) ||
|
|
(track.channels != 1 && track.channels != 2) ||
|
|
track.pcm.size() % track.channels != 0 ||
|
|
track.pcm.size() > kMaxAudioSamples - audioSamples ||
|
|
track.frameTriggers.size() > document.frames.size() ||
|
|
(i == 0 && !track.frameTriggers.empty())) {
|
|
error = "invalid audio track"; return false;
|
|
}
|
|
std::uint16_t previous = 0;
|
|
for (std::size_t cue = 0; cue < track.frameTriggers.size(); ++cue) {
|
|
const std::uint16_t frame = track.frameTriggers[cue];
|
|
if (frame >= document.frames.size() || (cue != 0 && frame <= previous)) {
|
|
error = "audio frame triggers must be sorted, unique and in range"; return false;
|
|
}
|
|
previous = frame;
|
|
}
|
|
audioSamples += track.pcm.size();
|
|
}
|
|
error.clear();
|
|
return true;
|
|
}
|
|
|
|
bool serialize(const Document& document, std::vector<std::uint8_t>& bytes,
|
|
std::string& error) {
|
|
if (!validate(document, error)) return false;
|
|
Writer w;
|
|
w.bytes.reserve(kHeaderBytes + kPaletteSize * 4 + document.frames.size() * 256);
|
|
w.raw(reinterpret_cast<const std::uint8_t*>("SPPM"), 4);
|
|
w.u16(kVersion);
|
|
w.u16(kHeaderBytes);
|
|
w.u64(0); // total size, patched below
|
|
w.u32(0); // CRC-32 of everything after the header
|
|
w.u16(kWidth);
|
|
w.u16(kHeight);
|
|
w.u8(static_cast<std::uint8_t>(kLayerCount));
|
|
w.u16(static_cast<std::uint16_t>(kPaletteSize));
|
|
w.u8(document.metadata.fps);
|
|
w.u8(static_cast<std::uint8_t>((document.metadata.locked ? 1 : 0) |
|
|
(document.metadata.allowSpinoffs ? 2 : 0) |
|
|
(document.metadata.bgmFollowsSpeed ? 4 : 0) |
|
|
(document.metadata.loopPlayback ? 0 : 8)));
|
|
w.u32(static_cast<std::uint32_t>(document.frames.size()));
|
|
w.u8(static_cast<std::uint8_t>(document.audioTracks.size()));
|
|
w.u16(document.metadata.speed); // v3 playback speed index; 0 retains legacy fps behavior
|
|
if (w.bytes.size() != kHeaderBytes) { error = "internal header size error"; return false; }
|
|
|
|
for (const Color& c : document.palette) { w.u8(c.r); w.u8(c.g); w.u8(c.b); w.u8(c.a); }
|
|
const Metadata& m = document.metadata;
|
|
w.text(m.title); w.text(m.description); w.text(m.authorName);
|
|
w.text(m.originalAuthorName); w.text(m.lastEditorName);
|
|
w.id(m.authorFsid); w.id(m.originalAuthorFsid); w.id(m.lastEditorFsid);
|
|
w.id(m.currentId); w.id(m.rootId); w.id(m.parentId);
|
|
w.u64(m.createdUnixSeconds); w.u64(m.modifiedUnixSeconds); w.u32(m.coverFrame);
|
|
|
|
for (const Frame& frame : document.frames) {
|
|
for (const auto& layer : frame.layers) {
|
|
const std::vector<std::uint8_t> encoded = encodeLayer(layer);
|
|
if (encoded.size() > kMaxLayerRleBytes) { error = "layer compression exceeded limit"; return false; }
|
|
w.u8(1); // RLE codec
|
|
w.u32(static_cast<std::uint32_t>(encoded.size()));
|
|
w.u32(crc32(encoded.data(), encoded.size()));
|
|
w.raw(encoded.data(), encoded.size());
|
|
}
|
|
}
|
|
for (std::size_t i = 0; i < document.audioTracks.size(); ++i) {
|
|
const AudioTrack& track = document.audioTracks[i];
|
|
w.u8(static_cast<std::uint8_t>(i));
|
|
w.u8(track.channels);
|
|
w.u8(track.referenceSpeed ? track.referenceSpeed : track.referenceFps);
|
|
w.u32(track.sampleRate);
|
|
w.text(track.name);
|
|
w.u16(static_cast<std::uint16_t>(track.frameTriggers.size()));
|
|
for (std::uint16_t frame : track.frameTriggers) w.u16(frame);
|
|
w.u32(static_cast<std::uint32_t>(track.pcm.size()));
|
|
std::vector<std::uint8_t> pcmBytes;
|
|
pcmBytes.reserve(track.pcm.size() * 2);
|
|
for (std::int16_t sample : track.pcm) {
|
|
const auto bits = static_cast<std::uint16_t>(sample);
|
|
pcmBytes.push_back(static_cast<std::uint8_t>(bits));
|
|
pcmBytes.push_back(static_cast<std::uint8_t>(bits >> 8));
|
|
}
|
|
w.u32(crc32(pcmBytes.data(), pcmBytes.size()));
|
|
w.raw(pcmBytes.data(), pcmBytes.size());
|
|
}
|
|
if (w.bytes.size() > kMaxFileBytes) { error = "file is too large"; return false; }
|
|
w.setU64(8, w.bytes.size());
|
|
w.setU32(16, crc32(w.bytes.data() + kHeaderBytes, w.bytes.size() - kHeaderBytes));
|
|
bytes.swap(w.bytes);
|
|
error.clear();
|
|
return true;
|
|
}
|
|
|
|
bool deserialize(const std::vector<std::uint8_t>& bytes, Document& document,
|
|
std::string& error) {
|
|
if (bytes.size() < kHeaderBytes || bytes.size() > kMaxFileBytes) {
|
|
error = "file size is outside supported bounds"; return false;
|
|
}
|
|
Reader r(bytes);
|
|
const std::uint8_t* magic;
|
|
std::uint16_t version, headerBytes, width, height, paletteCount, reserved;
|
|
std::uint64_t totalBytes;
|
|
std::uint32_t payloadCrc, frameCount;
|
|
std::uint8_t layerCount, fps, flags, audioCount;
|
|
if (!r.take(4, magic) || std::memcmp(magic, "SPPM", 4) != 0 ||
|
|
!r.u16(version) || !r.u16(headerBytes) || !r.u64(totalBytes) ||
|
|
!r.u32(payloadCrc) || !r.u16(width) || !r.u16(height) ||
|
|
!r.u8(layerCount) || !r.u16(paletteCount) || !r.u8(fps) ||
|
|
!r.u8(flags) || !r.u32(frameCount) || !r.u8(audioCount) || !r.u16(reserved)) {
|
|
error = "invalid or truncated SPPM header"; return false;
|
|
}
|
|
if ((version < 1 || version > kVersion) || headerBytes != kHeaderBytes || totalBytes != bytes.size() ||
|
|
width != kWidth || height != kHeight || layerCount != kLayerCount ||
|
|
paletteCount != kPaletteSize || fps < 1 || fps > 30 ||
|
|
(version == kLegacyVersion ? (flags & ~3U) != 0 : (flags & ~15U) != 0) ||
|
|
frameCount == 0 || frameCount > kMaxFrames || audioCount > kMaxAudioTracks ||
|
|
(version < 3 ? reserved != 0 : reserved > 8) ||
|
|
crc32(bytes.data() + kHeaderBytes, bytes.size() - kHeaderBytes) != payloadCrc) {
|
|
error = "unsupported SPPM layout or failed file CRC"; return false;
|
|
}
|
|
Document parsed;
|
|
parsed.metadata.fps = fps;
|
|
parsed.metadata.speed = version >= 3 ? static_cast<std::uint8_t>(reserved) : 0;
|
|
parsed.metadata.locked = (flags & 1U) != 0;
|
|
parsed.metadata.allowSpinoffs = (flags & 2U) != 0;
|
|
parsed.metadata.bgmFollowsSpeed = version == kLegacyVersion || (flags & 4U) != 0;
|
|
parsed.metadata.loopPlayback = version == kLegacyVersion || (flags & 8U) == 0;
|
|
for (Color& c : parsed.palette) {
|
|
if (!r.u8(c.r) || !r.u8(c.g) || !r.u8(c.b) || !r.u8(c.a)) {
|
|
error = "truncated palette"; return false;
|
|
}
|
|
// Migrate the two original default swatches to the requested SPPM
|
|
// palette: dark navy becomes purple and lime becomes pink. Palette
|
|
// indices remain stable, so existing drawings keep their structure.
|
|
if (c.a && c.r == 26 && c.g == 40 && c.b == 72) c = {132, 66, 183, c.a};
|
|
else if (c.a && c.r == 92 && c.g == 224 && c.b == 36) c = {239, 68, 133, c.a};
|
|
}
|
|
Metadata& m = parsed.metadata;
|
|
if (!r.text(m.title, 256) || !r.text(m.description, 4096) ||
|
|
!r.text(m.authorName, 128) || !r.text(m.originalAuthorName, 128) ||
|
|
!r.text(m.lastEditorName, 128) || !r.id(m.authorFsid, false) ||
|
|
!r.id(m.originalAuthorFsid, false) || !r.id(m.lastEditorFsid, false) ||
|
|
!r.id(m.currentId, false) || !r.id(m.rootId, false) ||
|
|
!r.id(m.parentId, true) || !r.u64(m.createdUnixSeconds) ||
|
|
!r.u64(m.modifiedUnixSeconds) || !r.u32(m.coverFrame)) {
|
|
error = "invalid or truncated metadata"; return false;
|
|
}
|
|
parsed.frames.reserve(frameCount);
|
|
for (std::uint32_t frameIndex = 0; frameIndex < frameCount; ++frameIndex) {
|
|
Frame frame;
|
|
for (auto& layer : frame.layers) {
|
|
std::uint8_t codec;
|
|
std::uint32_t length, layerCrc;
|
|
const std::uint8_t* compressed;
|
|
if (!r.u8(codec) || !r.u32(length) || !r.u32(layerCrc) || codec != 1 ||
|
|
length == 0 || length > kMaxLayerRleBytes || !r.take(length, compressed) ||
|
|
crc32(compressed, length) != layerCrc || !decodeLayer(compressed, length, layer)) {
|
|
error = "invalid frame layer or failed layer CRC"; return false;
|
|
}
|
|
}
|
|
parsed.frames.push_back(std::move(frame));
|
|
}
|
|
std::size_t totalSamples = 0;
|
|
for (std::uint8_t i = 0; i < audioCount; ++i) {
|
|
AudioTrack track;
|
|
std::uint8_t trackIndex;
|
|
std::uint16_t triggerCount;
|
|
std::uint32_t sampleCount, pcmCrc;
|
|
if (!r.u8(trackIndex) || !r.u8(track.channels) ||
|
|
(version >= 2 && !r.u8(track.referenceFps)) || !r.u32(track.sampleRate) ||
|
|
!r.text(track.name, 64) || !r.u16(triggerCount) ||
|
|
trackIndex != i || triggerCount > frameCount || (i == 0 && triggerCount != 0)) {
|
|
error = "invalid audio metadata"; return false;
|
|
}
|
|
if (version == kLegacyVersion && i == 0) track.referenceFps = parsed.metadata.fps;
|
|
if (version >= 3 && parsed.metadata.speed != 0) {
|
|
if (i == 0) { track.referenceSpeed = track.referenceFps; track.referenceFps = 0; }
|
|
else if (track.referenceFps != 0) { error = "invalid sound-effect speed metadata"; return false; }
|
|
}
|
|
track.frameTriggers.resize(triggerCount);
|
|
for (std::uint16_t& frame : track.frameTriggers) {
|
|
if (!r.u16(frame)) { error = "truncated audio triggers"; return false; }
|
|
}
|
|
if (!r.u32(sampleCount) || !r.u32(pcmCrc) ||
|
|
sampleCount > kMaxAudioSamples - totalSamples ||
|
|
(track.channels != 1 && track.channels != 2) ||
|
|
sampleCount % track.channels != 0 ||
|
|
track.sampleRate < 8000 || track.sampleRate > 48000) {
|
|
error = "invalid audio metadata"; return false;
|
|
}
|
|
const std::uint8_t* pcmBytes;
|
|
if (!r.take(static_cast<std::size_t>(sampleCount) * 2, pcmBytes) ||
|
|
crc32(pcmBytes, static_cast<std::size_t>(sampleCount) * 2) != pcmCrc) {
|
|
error = "truncated audio or failed audio CRC"; return false;
|
|
}
|
|
track.pcm.resize(sampleCount);
|
|
for (std::size_t n = 0; n < sampleCount; ++n) {
|
|
const std::uint16_t bits = static_cast<std::uint16_t>(pcmBytes[n * 2] | (pcmBytes[n * 2 + 1] << 8));
|
|
track.pcm[n] = static_cast<std::int16_t>(bits);
|
|
}
|
|
totalSamples += sampleCount;
|
|
parsed.audioTracks.push_back(std::move(track));
|
|
}
|
|
if (r.at != bytes.size()) { error = "unexpected trailing data"; return false; }
|
|
if (!validate(parsed, error)) return false;
|
|
document = std::move(parsed);
|
|
error.clear();
|
|
return true;
|
|
}
|
|
|
|
bool loadFile(const std::string& path, Document& document, std::string& error) {
|
|
std::ifstream input(std::filesystem::path(path), std::ios::binary | std::ios::ate);
|
|
if (!input) { error = "could not open file"; return false; }
|
|
const std::streamoff size = input.tellg();
|
|
if (size < 0 || static_cast<std::uint64_t>(size) > kMaxFileBytes) {
|
|
error = "file is too large"; return false;
|
|
}
|
|
std::vector<std::uint8_t> bytes(static_cast<std::size_t>(size));
|
|
input.seekg(0, std::ios::beg);
|
|
if (!bytes.empty()) input.read(reinterpret_cast<char*>(bytes.data()), size);
|
|
if (!input && size != 0) { error = "could not read complete file"; return false; }
|
|
return deserialize(bytes, document, error);
|
|
}
|
|
|
|
bool saveFile(const std::string& path, const Document& document, std::string& error) {
|
|
std::vector<std::uint8_t> bytes;
|
|
if (!serialize(document, bytes, error)) return false;
|
|
return commitBytes(path, bytes, error);
|
|
}
|
|
|
|
namespace {
|
|
bool deserializePublicSigned(const std::vector<std::uint8_t>& bytes, Document& document, std::string& error);
|
|
}
|
|
|
|
bool deserializeSigned(const std::vector<std::uint8_t>& bytes, Document& document,
|
|
const std::array<std::uint8_t, 32>& signingKey,
|
|
std::string& error) {
|
|
if (bytes.size() >= kPublicSignatureBytes &&
|
|
std::memcmp(bytes.data() + bytes.size() - kPublicSignatureBytes, "SPP3", 4) == 0) {
|
|
return deserializePublicSigned(bytes, document, error);
|
|
}
|
|
if (bytes.size() < kSignatureBytes || std::memcmp(bytes.data() + bytes.size() - kSignatureBytes, "SPPS", 4) != 0) {
|
|
// Read-only compatibility for v1 notes; the next save upgrades them.
|
|
if (bytes.size() >= 6 && (bytes[4] != 1 || bytes[5] != 0)) {
|
|
error = "signed Switchnote is missing its signature";
|
|
return false;
|
|
}
|
|
return deserialize(bytes, document, error);
|
|
}
|
|
const std::size_t bodySize = bytes.size() - kSignatureBytes;
|
|
const auto expected = hmacSha256(signingKey, bytes.data(), bodySize);
|
|
unsigned difference = 0;
|
|
for (std::size_t i = 0; i < expected.size(); ++i) difference |= expected[i] ^ bytes[bodySize + 4 + i];
|
|
if (difference != 0) { error = "Switchnote signature is invalid"; return false; }
|
|
std::vector<std::uint8_t> body(bytes.begin(), bytes.begin() + static_cast<std::ptrdiff_t>(bodySize));
|
|
if (body.size() < 6 || (body[4] != 2 && body[4] != 3) || body[5] != 0) {
|
|
error = "unsupported signed Switchnote version";
|
|
return false;
|
|
}
|
|
// Earlier signed notes used a v1 payload with the same envelope marker.
|
|
// Authenticate first, then prefer the current layout and fall back to v1.
|
|
if (body[4] == 3 && deserialize(body, document, error)) return true;
|
|
body[4] = kVersion;
|
|
if (deserialize(body, document, error)) return true;
|
|
body[4] = kLegacyVersion;
|
|
return deserialize(body, document, error);
|
|
}
|
|
|
|
bool loadSignedFile(const std::string& path, Document& document,
|
|
const std::array<std::uint8_t, 32>& signingKey,
|
|
std::string& error) {
|
|
std::ifstream input(std::filesystem::path(path), std::ios::binary | std::ios::ate);
|
|
if (!input) { error = "could not open file"; return false; }
|
|
const std::streamoff size = input.tellg();
|
|
if (size < 0 || static_cast<std::uint64_t>(size) > kMaxFileBytes + kSignatureBytes) {
|
|
error = "file is too large"; return false;
|
|
}
|
|
std::vector<std::uint8_t> bytes(static_cast<std::size_t>(size));
|
|
input.seekg(0, std::ios::beg);
|
|
if (!bytes.empty()) input.read(reinterpret_cast<char*>(bytes.data()), size);
|
|
if (!input && size != 0) { error = "could not read complete file"; return false; }
|
|
return deserializeSigned(bytes, document, signingKey, error);
|
|
}
|
|
|
|
bool saveSignedFile(const std::string& path, const Document& document,
|
|
const std::array<std::uint8_t, 32>& signingKey,
|
|
std::string& error) {
|
|
std::vector<std::uint8_t> bytes;
|
|
if (!serialize(document, bytes, error)) return false;
|
|
bytes[4] = kVersion;
|
|
const auto signature = hmacSha256(signingKey, bytes.data(), bytes.size());
|
|
bytes.insert(bytes.end(), {'S', 'P', 'P', 'S'});
|
|
bytes.insert(bytes.end(), signature.begin(), signature.end());
|
|
return commitBytes(path, bytes, error);
|
|
}
|
|
|
|
namespace {
|
|
|
|
void appendIdentity(std::vector<std::uint8_t>& out, const PublicIdentity& identity) {
|
|
out.insert(out.end(), identity.key.begin(), identity.key.end());
|
|
out.insert(out.end(), identity.deviceTag.begin(), identity.deviceTag.end());
|
|
}
|
|
|
|
bool readIdentity(const std::vector<std::uint8_t>& bytes, std::size_t& offset, PublicIdentity& identity) {
|
|
if (offset > bytes.size() || bytes.size() - offset < identity.key.size() + identity.deviceTag.size()) return false;
|
|
std::copy_n(bytes.begin() + static_cast<std::ptrdiff_t>(offset), identity.key.size(), identity.key.begin());
|
|
offset += identity.key.size();
|
|
std::copy_n(bytes.begin() + static_cast<std::ptrdiff_t>(offset), identity.deviceTag.size(), identity.deviceTag.begin());
|
|
offset += identity.deviceTag.size();
|
|
return true;
|
|
}
|
|
|
|
std::array<std::uint8_t, 32> publicSignatureDigest(const std::vector<std::uint8_t>& body,
|
|
const PublicIdentity& author,
|
|
const PublicIdentity& original,
|
|
const PublicIdentity& editor) {
|
|
constexpr char domain[] = "FlipnoteNX-SPPM-public-signature-v3";
|
|
Sha256 hash;
|
|
hash.update(reinterpret_cast<const std::uint8_t*>(domain), sizeof(domain));
|
|
hash.update(body.data(), body.size());
|
|
hash.update(author.key.data(), author.key.size());
|
|
hash.update(author.deviceTag.data(), author.deviceTag.size());
|
|
hash.update(original.key.data(), original.key.size());
|
|
hash.update(original.deviceTag.data(), original.deviceTag.size());
|
|
hash.update(editor.key.data(), editor.key.size());
|
|
hash.update(editor.deviceTag.data(), editor.deviceTag.size());
|
|
return hash.finish();
|
|
}
|
|
|
|
bool deserializePublicSigned(const std::vector<std::uint8_t>& bytes, Document& document, std::string& error) {
|
|
if (bytes.size() < kPublicSignatureBytes) { error = "public signature is truncated"; return false; }
|
|
const std::size_t footer = bytes.size() - kPublicSignatureBytes;
|
|
if (std::memcmp(bytes.data() + footer, "SPP3", 4) != 0) { error = "public signature marker is invalid"; return false; }
|
|
std::size_t offset = footer + 4;
|
|
PublicIdentity author, original, editor;
|
|
if (!readIdentity(bytes, offset, author) || !readIdentity(bytes, offset, original) || !readIdentity(bytes, offset, editor) ||
|
|
bytes.size() - offset != 64 || !hasPublicIdentity(editor)) {
|
|
error = "public identity footer is invalid"; return false;
|
|
}
|
|
std::vector<std::uint8_t> body(bytes.begin(), bytes.begin() + static_cast<std::ptrdiff_t>(footer));
|
|
if (body.size() < 6 || body[4] != 3 || body[5] != 0) { error = "unsupported public signed Switchnote version"; return false; }
|
|
const auto digest = publicSignatureDigest(body, author, original, editor);
|
|
std::array<std::uint8_t, 96> signedDigest{};
|
|
std::copy_n(bytes.begin() + static_cast<std::ptrdiff_t>(offset), 64, signedDigest.begin());
|
|
std::copy(digest.begin(), digest.end(), signedDigest.begin() + 64);
|
|
// crypto_sign_open copies the complete message into its output buffer
|
|
// before checking the signature. The signed payload here is 64 bytes of
|
|
// Ed25519 signature plus the 32-byte digest, so the destination must be
|
|
// the full 96 bytes even though only the digest is compared below.
|
|
std::array<std::uint8_t, 96> verifiedMessage{};
|
|
unsigned long long verifiedLength = 0;
|
|
if (crypto_sign_ed25519_tweet_open(verifiedMessage.data(), &verifiedLength, signedDigest.data(), signedDigest.size(), editor.key.data()) != 0 ||
|
|
verifiedLength != digest.size() || !std::equal(digest.begin(), digest.end(), verifiedMessage.begin())) {
|
|
error = "Switchnote public signature is invalid"; return false;
|
|
}
|
|
Document parsed;
|
|
body[4] = kVersion;
|
|
if (!deserialize(body, parsed, error)) {
|
|
body[4] = kLegacyVersion;
|
|
if (!deserialize(body, parsed, error)) return false;
|
|
}
|
|
parsed.metadata.authorIdentity = author;
|
|
parsed.metadata.originalAuthorIdentity = original;
|
|
parsed.metadata.lastEditorIdentity = editor;
|
|
if (!validate(parsed, error)) return false;
|
|
document = std::move(parsed);
|
|
error.clear();
|
|
return true;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
bool savePublicSignedFile(const std::string& path, const Document& document,
|
|
const SigningIdentity& identity, std::string& error) {
|
|
if (!hasPublicIdentity(identity.publicIdentity) ||
|
|
!sameId(deriveFsid(identity.publicIdentity), document.metadata.lastEditorFsid) ||
|
|
!hasPublicIdentity(document.metadata.lastEditorIdentity) ||
|
|
document.metadata.lastEditorIdentity.key != identity.publicIdentity.key ||
|
|
document.metadata.lastEditorIdentity.deviceTag != identity.publicIdentity.deviceTag) {
|
|
error = "document last editor does not match signing identity";
|
|
return false;
|
|
}
|
|
std::vector<std::uint8_t> body;
|
|
if (!serialize(document, body, error)) return false;
|
|
body[4] = 3;
|
|
const auto digest = publicSignatureDigest(body, document.metadata.authorIdentity,
|
|
document.metadata.originalAuthorIdentity,
|
|
document.metadata.lastEditorIdentity);
|
|
std::array<std::uint8_t, 96> signedDigest{};
|
|
unsigned long long signedLength = 0;
|
|
if (crypto_sign_ed25519_tweet(signedDigest.data(), &signedLength, digest.data(), digest.size(), identity.secretKey.data()) != 0 ||
|
|
signedLength != signedDigest.size()) {
|
|
error = "could not sign Switchnote";
|
|
return false;
|
|
}
|
|
body.insert(body.end(), {'S', 'P', 'P', '3'});
|
|
appendIdentity(body, document.metadata.authorIdentity);
|
|
appendIdentity(body, document.metadata.originalAuthorIdentity);
|
|
appendIdentity(body, document.metadata.lastEditorIdentity);
|
|
body.insert(body.end(), signedDigest.begin(), signedDigest.begin() + 64);
|
|
return commitBytes(path, body, error);
|
|
}
|
|
|
|
bool signMessage(const SigningIdentity& identity, const std::uint8_t* message,
|
|
std::size_t messageSize, std::array<std::uint8_t, 64>& signature,
|
|
std::string& error) {
|
|
if (!hasPublicIdentity(identity.publicIdentity) || (messageSize && !message) ||
|
|
!std::equal(identity.publicIdentity.key.begin(), identity.publicIdentity.key.end(),
|
|
identity.secretKey.begin() + 32) ||
|
|
messageSize > std::numeric_limits<std::size_t>::max() - 64 ||
|
|
messageSize > std::numeric_limits<unsigned long long>::max() - 64) {
|
|
error = "invalid signing identity or request message";
|
|
return false;
|
|
}
|
|
std::vector<unsigned char> signedMessage(messageSize + 64);
|
|
unsigned long long signedLength = 0;
|
|
if (crypto_sign_ed25519_tweet(signedMessage.data(), &signedLength, message,
|
|
static_cast<unsigned long long>(messageSize),
|
|
identity.secretKey.data()) != 0 ||
|
|
signedLength != signedMessage.size()) {
|
|
std::fill(signedMessage.begin(), signedMessage.end(), 0);
|
|
error = "could not sign Gallery request";
|
|
return false;
|
|
}
|
|
std::copy_n(signedMessage.begin(), signature.size(), signature.begin());
|
|
std::fill(signedMessage.begin(), signedMessage.end(), 0);
|
|
error.clear();
|
|
return true;
|
|
}
|
|
|
|
bool createSpinoff(const Document& parent, std::string_view newAuthorName,
|
|
std::string_view newAuthorFsid, std::string_view newCurrentId,
|
|
std::uint64_t nowUnixSeconds, Document& child,
|
|
std::string& error) {
|
|
if (!validate(parent, error)) return false;
|
|
if (parent.metadata.locked || !parent.metadata.allowSpinoffs) {
|
|
error = "this Switchnote does not permit spin-offs"; return false;
|
|
}
|
|
if (newAuthorName.size() > 128 || newAuthorName.find('\0') != std::string_view::npos ||
|
|
!validUtf8(newAuthorName) ||
|
|
!isValidFsid(newAuthorFsid) || !isValidFsid(newCurrentId) ||
|
|
sameId(newCurrentId, parent.metadata.currentId) ||
|
|
sameId(newCurrentId, parent.metadata.rootId) ||
|
|
nowUnixSeconds < parent.metadata.createdUnixSeconds) {
|
|
error = "invalid spin-off identity or creation time"; return false;
|
|
}
|
|
Document fork = parent;
|
|
Metadata& m = fork.metadata;
|
|
m.authorName.assign(newAuthorName);
|
|
m.authorFsid = upperId(newAuthorFsid);
|
|
m.authorIdentity = {};
|
|
m.lastEditorName = m.authorName;
|
|
m.lastEditorFsid = m.authorFsid;
|
|
m.lastEditorIdentity = {};
|
|
m.parentId = upperId(parent.metadata.currentId);
|
|
m.currentId = upperId(newCurrentId);
|
|
m.createdUnixSeconds = nowUnixSeconds;
|
|
m.modifiedUnixSeconds = nowUnixSeconds;
|
|
m.locked = false;
|
|
if (!validate(fork, error)) return false;
|
|
child = std::move(fork);
|
|
error.clear();
|
|
return true;
|
|
}
|
|
|
|
} // namespace sppm
|
