@majikah/majik-key 0.5.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/backup/error.d.ts +28 -0
- package/dist/core/backup/error.js +41 -0
- package/dist/core/backup/index.d.ts +4 -0
- package/dist/core/backup/index.js +3 -0
- package/dist/core/backup/majik-key-backup.d.ts +49 -0
- package/dist/core/backup/majik-key-backup.js +193 -0
- package/dist/core/backup/types.d.ts +22 -0
- package/dist/core/backup/types.js +8 -0
- package/dist/core/backup/utils.d.ts +14 -0
- package/dist/core/backup/utils.js +92 -0
- package/dist/core/backup/validator.d.ts +11 -0
- package/dist/core/backup/validator.js +36 -0
- package/dist/core/crypto/crypto-provider.js +2 -2
- package/dist/core/types.d.ts +2 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/majik-key.d.ts +3 -1
- package/dist/majik-key.js +7 -15
- package/package.json +20 -9
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* All errors this module throws. Typed so callers can branch on
|
|
3
|
+
* `instanceof` instead of string-matching `.message` — and so every
|
|
4
|
+
* failure is loud and specific rather than a generic Error/undefined.
|
|
5
|
+
*/
|
|
6
|
+
export declare class MajikKeyBackupError extends Error {
|
|
7
|
+
constructor(message: string);
|
|
8
|
+
}
|
|
9
|
+
/** An optional peer dependency (jszip, @majikah/majik-bytes) wasn't resolvable at runtime. */
|
|
10
|
+
export declare class MissingOptionalDependencyError extends MajikKeyBackupError {
|
|
11
|
+
constructor(pkg: string, feature: string);
|
|
12
|
+
}
|
|
13
|
+
/** A JSON payload (bare, or decoded from a PNG/zip) failed shape validation. */
|
|
14
|
+
export declare class InvalidBackupJSONError extends MajikKeyBackupError {
|
|
15
|
+
constructor(reason: string);
|
|
16
|
+
}
|
|
17
|
+
/** A PNG file wasn't a valid MajikByte, or its decoded payload wasn't a valid backup. */
|
|
18
|
+
export declare class InvalidBackupPNGError extends MajikKeyBackupError {
|
|
19
|
+
constructor(reason: string);
|
|
20
|
+
}
|
|
21
|
+
/** A .zip archive contained no valid backup.png or backup.json anywhere inside it. */
|
|
22
|
+
export declare class InvalidBackupZipError extends MajikKeyBackupError {
|
|
23
|
+
constructor(reason: string);
|
|
24
|
+
}
|
|
25
|
+
/** A zip contained both a valid PNG backup and a valid JSON backup, but they describe different accounts. */
|
|
26
|
+
export declare class BackupIntegrityMismatchError extends MajikKeyBackupError {
|
|
27
|
+
constructor(details: string);
|
|
28
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* All errors this module throws. Typed so callers can branch on
|
|
3
|
+
* `instanceof` instead of string-matching `.message` — and so every
|
|
4
|
+
* failure is loud and specific rather than a generic Error/undefined.
|
|
5
|
+
*/
|
|
6
|
+
export class MajikKeyBackupError extends Error {
|
|
7
|
+
constructor(message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = this.constructor.name;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/** An optional peer dependency (jszip, @majikah/majik-bytes) wasn't resolvable at runtime. */
|
|
13
|
+
export class MissingOptionalDependencyError extends MajikKeyBackupError {
|
|
14
|
+
constructor(pkg, feature) {
|
|
15
|
+
super(`"${pkg}" is required to ${feature}. Install it with \`npm install ${pkg}\`.`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** A JSON payload (bare, or decoded from a PNG/zip) failed shape validation. */
|
|
19
|
+
export class InvalidBackupJSONError extends MajikKeyBackupError {
|
|
20
|
+
constructor(reason) {
|
|
21
|
+
super(`Invalid backup JSON: ${reason}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** A PNG file wasn't a valid MajikByte, or its decoded payload wasn't a valid backup. */
|
|
25
|
+
export class InvalidBackupPNGError extends MajikKeyBackupError {
|
|
26
|
+
constructor(reason) {
|
|
27
|
+
super(`Invalid backup PNG: ${reason}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** A .zip archive contained no valid backup.png or backup.json anywhere inside it. */
|
|
31
|
+
export class InvalidBackupZipError extends MajikKeyBackupError {
|
|
32
|
+
constructor(reason) {
|
|
33
|
+
super(`Invalid backup archive: ${reason}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** A zip contained both a valid PNG backup and a valid JSON backup, but they describe different accounts. */
|
|
37
|
+
export class BackupIntegrityMismatchError extends MajikKeyBackupError {
|
|
38
|
+
constructor(details) {
|
|
39
|
+
super(`Backup archive contains conflicting payloads: ${details}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { MajikKeyBackup } from "./majik-key-backup";
|
|
2
|
+
export type { BackupSource, CreateBackupParams, ToZipOptions } from "./types";
|
|
3
|
+
export { BACKUP_FORMAT_VERSION } from "./types";
|
|
4
|
+
export { MajikKeyBackupError, MissingOptionalDependencyError, InvalidBackupJSONError, InvalidBackupPNGError, InvalidBackupZipError, BackupIntegrityMismatchError, } from "./error";
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { MajikKeyBackup } from "./majik-key-backup";
|
|
2
|
+
export { BACKUP_FORMAT_VERSION } from "./types";
|
|
3
|
+
export { MajikKeyBackupError, MissingOptionalDependencyError, InvalidBackupJSONError, InvalidBackupPNGError, InvalidBackupZipError, BackupIntegrityMismatchError, } from "./error";
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { MnemonicLanguage } from "../crypto/wordlist";
|
|
2
|
+
import { MnemonicJSON } from "../types";
|
|
3
|
+
import { CreateBackupParams, ToZipOptions } from "./types";
|
|
4
|
+
/**
|
|
5
|
+
* A validated Majik Key backup payload, and the single place that knows
|
|
6
|
+
* how to read/write it as JSON, a MajikByte PNG, or a .zip archive
|
|
7
|
+
* containing both plus a README.
|
|
8
|
+
*
|
|
9
|
+
* Every construction path (create, fromJSON, fromPNG, fromZIP) funnels
|
|
10
|
+
* through the same shape validator, so there is exactly one definition
|
|
11
|
+
* of "valid backup" anywhere in the app.
|
|
12
|
+
*/
|
|
13
|
+
export declare class MajikKeyBackup {
|
|
14
|
+
private readonly data;
|
|
15
|
+
private constructor();
|
|
16
|
+
/** Builds a fresh backup from a newly generated seed. Pure — no I/O. */
|
|
17
|
+
static create(params: CreateBackupParams): MajikKeyBackup;
|
|
18
|
+
/** Validates and wraps an already-parsed JSON payload (e.g. a bare .json file the user dropped, no zip/PNG involved). */
|
|
19
|
+
static fromJSON(input: unknown): MajikKeyBackup;
|
|
20
|
+
/** Decodes a MajikByte PNG backup and validates the embedded payload. */
|
|
21
|
+
static fromPNG(file: File | Blob): Promise<MajikKeyBackup>;
|
|
22
|
+
/**
|
|
23
|
+
* Parses a `.zip` backup archive. Walks every file entry — JSZip
|
|
24
|
+
* already flattens nested folders into full relative paths in
|
|
25
|
+
* `archive.files`, so no manual recursion is needed even for a zip
|
|
26
|
+
* re-created one folder level deeper than expected.
|
|
27
|
+
*
|
|
28
|
+
* Classification is by magic bytes, not filename/extension, so a
|
|
29
|
+
* renamed or oddly-cased file is still found.
|
|
30
|
+
*
|
|
31
|
+
* PNG wins when both a valid PNG and a valid JSON backup are present
|
|
32
|
+
* (harder to tamper with — carries the MajikByte integrity check).
|
|
33
|
+
* Falls back to JSON only if no valid PNG is found anywhere in the
|
|
34
|
+
* archive. If both are present but describe different accounts,
|
|
35
|
+
* that's a real integrity problem and is surfaced as one, not
|
|
36
|
+
* silently resolved by picking a winner.
|
|
37
|
+
*/
|
|
38
|
+
static fromZIP(file: File | Blob | Uint8Array): Promise<MajikKeyBackup>;
|
|
39
|
+
toJSON(): MnemonicJSON;
|
|
40
|
+
get id(): string;
|
|
41
|
+
get seed(): string[];
|
|
42
|
+
get seedPhrase(): string;
|
|
43
|
+
get language(): MnemonicLanguage | undefined;
|
|
44
|
+
get formatVersion(): number | undefined;
|
|
45
|
+
toPNG(): Promise<Blob>;
|
|
46
|
+
toZIP(_opts?: ToZipOptions): Promise<Blob>;
|
|
47
|
+
/** Convenience for callers building a native `save()` dialog default path. */
|
|
48
|
+
suggestedFileName(label?: string): string;
|
|
49
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { validateMnemonicJSONShape } from "./validator";
|
|
2
|
+
import { InvalidBackupPNGError, InvalidBackupZipError, BackupIntegrityMismatchError, } from "./error";
|
|
3
|
+
import { getJSZip, getMajikBytes, looksLikePNG, toSafeFileName, buildReadmeText, } from "./utils";
|
|
4
|
+
import { BACKUP_FORMAT_VERSION, } from "./types";
|
|
5
|
+
import { base64ToUtf8, utf8ToBase64 } from "../utils";
|
|
6
|
+
const BACKUP_JSON_FILENAME = "backup.json";
|
|
7
|
+
const BACKUP_PNG_FILENAME = "backup.png";
|
|
8
|
+
const README_FILENAME = "IMPORTANT README.txt";
|
|
9
|
+
/**
|
|
10
|
+
* A validated Majik Key backup payload, and the single place that knows
|
|
11
|
+
* how to read/write it as JSON, a MajikByte PNG, or a .zip archive
|
|
12
|
+
* containing both plus a README.
|
|
13
|
+
*
|
|
14
|
+
* Every construction path (create, fromJSON, fromPNG, fromZIP) funnels
|
|
15
|
+
* through the same shape validator, so there is exactly one definition
|
|
16
|
+
* of "valid backup" anywhere in the app.
|
|
17
|
+
*/
|
|
18
|
+
export class MajikKeyBackup {
|
|
19
|
+
data;
|
|
20
|
+
constructor(data) {
|
|
21
|
+
this.data = data;
|
|
22
|
+
}
|
|
23
|
+
// ────────────────────────────────────────────────────────────────
|
|
24
|
+
// Static constructors
|
|
25
|
+
// ────────────────────────────────────────────────────────────────
|
|
26
|
+
/** Builds a fresh backup from a newly generated seed. Pure — no I/O. */
|
|
27
|
+
static create(params) {
|
|
28
|
+
const seedArray = Array.isArray(params.seed)
|
|
29
|
+
? params.seed
|
|
30
|
+
: params.seed.trim().split(/\s+/);
|
|
31
|
+
const json = {
|
|
32
|
+
id: params.id,
|
|
33
|
+
seed: seedArray,
|
|
34
|
+
language: params.language,
|
|
35
|
+
phrase: params.phrase,
|
|
36
|
+
version: BACKUP_FORMAT_VERSION,
|
|
37
|
+
};
|
|
38
|
+
validateMnemonicJSONShape(json);
|
|
39
|
+
return new MajikKeyBackup(json);
|
|
40
|
+
}
|
|
41
|
+
/** Validates and wraps an already-parsed JSON payload (e.g. a bare .json file the user dropped, no zip/PNG involved). */
|
|
42
|
+
static fromJSON(input) {
|
|
43
|
+
validateMnemonicJSONShape(input);
|
|
44
|
+
return new MajikKeyBackup(input);
|
|
45
|
+
}
|
|
46
|
+
/** Decodes a MajikByte PNG backup and validates the embedded payload. */
|
|
47
|
+
static async fromPNG(file) {
|
|
48
|
+
const { MajikBytes } = await getMajikBytes();
|
|
49
|
+
const check = await MajikBytes.isValidPNG(file);
|
|
50
|
+
if (!check?.isValid) {
|
|
51
|
+
throw new InvalidBackupPNGError("not a recognized MajikByte PNG");
|
|
52
|
+
}
|
|
53
|
+
let decoded;
|
|
54
|
+
try {
|
|
55
|
+
const mbyte = await MajikBytes.fromPNG(file);
|
|
56
|
+
const base64 = mbyte.toStringValue();
|
|
57
|
+
decoded = JSON.parse(base64ToUtf8(base64));
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
throw new InvalidBackupPNGError(`could not decode embedded payload (${err?.message ?? err})`);
|
|
61
|
+
}
|
|
62
|
+
validateMnemonicJSONShape(decoded);
|
|
63
|
+
return new MajikKeyBackup(decoded);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Parses a `.zip` backup archive. Walks every file entry — JSZip
|
|
67
|
+
* already flattens nested folders into full relative paths in
|
|
68
|
+
* `archive.files`, so no manual recursion is needed even for a zip
|
|
69
|
+
* re-created one folder level deeper than expected.
|
|
70
|
+
*
|
|
71
|
+
* Classification is by magic bytes, not filename/extension, so a
|
|
72
|
+
* renamed or oddly-cased file is still found.
|
|
73
|
+
*
|
|
74
|
+
* PNG wins when both a valid PNG and a valid JSON backup are present
|
|
75
|
+
* (harder to tamper with — carries the MajikByte integrity check).
|
|
76
|
+
* Falls back to JSON only if no valid PNG is found anywhere in the
|
|
77
|
+
* archive. If both are present but describe different accounts,
|
|
78
|
+
* that's a real integrity problem and is surfaced as one, not
|
|
79
|
+
* silently resolved by picking a winner.
|
|
80
|
+
*/
|
|
81
|
+
static async fromZIP(file) {
|
|
82
|
+
const JSZip = await getJSZip();
|
|
83
|
+
let archive;
|
|
84
|
+
try {
|
|
85
|
+
archive = await JSZip.loadAsync(file);
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
throw new InvalidBackupZipError(`could not open archive (${err?.message ?? err})`);
|
|
89
|
+
}
|
|
90
|
+
let pngResult = null;
|
|
91
|
+
let pngError = null;
|
|
92
|
+
let jsonResult = null;
|
|
93
|
+
for (const relativePath of Object.keys(archive.files)) {
|
|
94
|
+
const entry = archive.files[relativePath];
|
|
95
|
+
if (entry.dir)
|
|
96
|
+
continue;
|
|
97
|
+
const bytes = await entry.async("uint8array");
|
|
98
|
+
if (!pngResult && looksLikePNG(bytes)) {
|
|
99
|
+
try {
|
|
100
|
+
const blob = new Blob([bytes], { type: "image/png" });
|
|
101
|
+
pngResult = await MajikKeyBackup.fromPNG(blob);
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
// Keep the first PNG-shaped-but-invalid error for the final
|
|
105
|
+
// message, but keep scanning — a later entry might still be
|
|
106
|
+
// a valid JSON backup.
|
|
107
|
+
pngError = pngError ?? err;
|
|
108
|
+
}
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (!jsonResult) {
|
|
112
|
+
try {
|
|
113
|
+
const text = new TextDecoder("utf-8").decode(bytes);
|
|
114
|
+
const parsed = JSON.parse(text);
|
|
115
|
+
jsonResult = MajikKeyBackup.fromJSON(parsed);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// Not every non-PNG entry is the backup JSON (e.g. the
|
|
119
|
+
// README) — skip silently, we only error if *nothing* valid
|
|
120
|
+
// turns up anywhere in the archive.
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (pngResult && jsonResult) {
|
|
125
|
+
const idsMatch = pngResult.data.id === jsonResult.data.id;
|
|
126
|
+
const seedsMatch = pngResult.data.seed.join(" ") === jsonResult.data.seed.join(" ");
|
|
127
|
+
if (!idsMatch || !seedsMatch) {
|
|
128
|
+
throw new BackupIntegrityMismatchError("the PNG and JSON backups inside this archive do not describe the same account");
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (pngResult)
|
|
132
|
+
return pngResult;
|
|
133
|
+
if (jsonResult)
|
|
134
|
+
return jsonResult;
|
|
135
|
+
const reason = pngError
|
|
136
|
+
? `a PNG-shaped file was found but is invalid (${pngError?.message ?? pngError})`
|
|
137
|
+
: "no valid backup.png or backup.json found inside the archive";
|
|
138
|
+
throw new InvalidBackupZipError(reason);
|
|
139
|
+
}
|
|
140
|
+
// ────────────────────────────────────────────────────────────────
|
|
141
|
+
// Instance accessors
|
|
142
|
+
// ────────────────────────────────────────────────────────────────
|
|
143
|
+
toJSON() {
|
|
144
|
+
return { ...this.data };
|
|
145
|
+
}
|
|
146
|
+
get id() {
|
|
147
|
+
return this.data.id;
|
|
148
|
+
}
|
|
149
|
+
get seed() {
|
|
150
|
+
return [...this.data.seed];
|
|
151
|
+
}
|
|
152
|
+
get seedPhrase() {
|
|
153
|
+
return this.data.seed.join(" ");
|
|
154
|
+
}
|
|
155
|
+
get language() {
|
|
156
|
+
return this.data.language;
|
|
157
|
+
}
|
|
158
|
+
get formatVersion() {
|
|
159
|
+
return this.data.version;
|
|
160
|
+
}
|
|
161
|
+
// ────────────────────────────────────────────────────────────────
|
|
162
|
+
// Instance serializers
|
|
163
|
+
// ────────────────────────────────────────────────────────────────
|
|
164
|
+
async toPNG() {
|
|
165
|
+
const { MajikBytes } = await getMajikBytes();
|
|
166
|
+
const base64 = utf8ToBase64(JSON.stringify(this.toJSON()));
|
|
167
|
+
const mbyte = await MajikBytes.create(base64);
|
|
168
|
+
return mbyte.toPNG();
|
|
169
|
+
}
|
|
170
|
+
async toZIP(_opts = {}) {
|
|
171
|
+
const JSZip = await getJSZip();
|
|
172
|
+
const json = this.toJSON();
|
|
173
|
+
const pngBlob = await this.toPNG();
|
|
174
|
+
const pngBuffer = await pngBlob.arrayBuffer();
|
|
175
|
+
const zip = new JSZip();
|
|
176
|
+
zip.file(BACKUP_JSON_FILENAME, JSON.stringify(json));
|
|
177
|
+
zip.file(BACKUP_PNG_FILENAME, pngBuffer, { binary: true });
|
|
178
|
+
zip.file(README_FILENAME, buildReadmeText(new Date()));
|
|
179
|
+
return zip.generateAsync({
|
|
180
|
+
type: "blob",
|
|
181
|
+
compression: "DEFLATE",
|
|
182
|
+
compressionOptions: { level: 9 },
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
/** Convenience for callers building a native `save()` dialog default path. */
|
|
186
|
+
suggestedFileName(label) {
|
|
187
|
+
return toSafeFileName(`${label ?? "Majik Key"} - ${this.data.id} - SEED KEY - ${new Date().toISOString()}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// Freeze static methods (e.g., MajikKey.create, MajikKey.fromJSON)
|
|
191
|
+
Object.freeze(MajikKeyBackup);
|
|
192
|
+
// Freeze instance methods (e.g., this.lock, this.unlock)
|
|
193
|
+
Object.freeze(MajikKeyBackup.prototype);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { MnemonicLanguage } from "../crypto/wordlist";
|
|
2
|
+
/**
|
|
3
|
+
* Bump this whenever the *shape* of MnemonicJSON or the zip layout
|
|
4
|
+
* changes in a way that could break parsing of older backups. Written
|
|
5
|
+
* into every backup created by `MajikKeyBackup.create()`; read back
|
|
6
|
+
* (but not currently enforced) by `fromJSON`/`fromPNG`/`fromZIP`, so
|
|
7
|
+
* future versions can branch on it if the shape ever diverges.
|
|
8
|
+
*/
|
|
9
|
+
export declare const BACKUP_FORMAT_VERSION = 1;
|
|
10
|
+
/** Which artifact inside a parsed backup archive supplied the winning payload. */
|
|
11
|
+
export type BackupSource = "png" | "json";
|
|
12
|
+
export interface CreateBackupParams {
|
|
13
|
+
/** Either the full mnemonic string ("word1 word2 ...") or a pre-split word array. */
|
|
14
|
+
seed: string | string[];
|
|
15
|
+
id: string;
|
|
16
|
+
language: MnemonicLanguage;
|
|
17
|
+
phrase?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface ToZipOptions {
|
|
20
|
+
/** Used only to build the human-facing filename hint (suggestedFileName); purely cosmetic. */
|
|
21
|
+
label?: string;
|
|
22
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bump this whenever the *shape* of MnemonicJSON or the zip layout
|
|
3
|
+
* changes in a way that could break parsing of older backups. Written
|
|
4
|
+
* into every backup created by `MajikKeyBackup.create()`; read back
|
|
5
|
+
* (but not currently enforced) by `fromJSON`/`fromPNG`/`fromZIP`, so
|
|
6
|
+
* future versions can branch on it if the shape ever diverges.
|
|
7
|
+
*/
|
|
8
|
+
export const BACKUP_FORMAT_VERSION = 1;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare function getJSZip(): Promise<any>;
|
|
2
|
+
export declare function getMajikBytes(): Promise<any>;
|
|
3
|
+
export declare function looksLikePNG(bytes: Uint8Array): boolean;
|
|
4
|
+
/** Zip local-file-header signature ("PK\x03\x04"), also covers empty-archive/spanned variants starting "PK". */
|
|
5
|
+
export declare function looksLikeZip(bytes: Uint8Array): boolean;
|
|
6
|
+
export declare function toSafeFileName(value: string): string;
|
|
7
|
+
/**
|
|
8
|
+
* Static body of the backup README. Update this whenever the copy
|
|
9
|
+
* needs to change — every zip generated by toZIP() picks it up
|
|
10
|
+
* automatically, nothing else needs to change.
|
|
11
|
+
*/
|
|
12
|
+
export declare const README_TEXT = "Majik Key Backup\n\nIMPORTANT: Keep this file secure and private at all times. If lost or compromised, your account access may be permanently at risk.\n\nOverview\nThis backup ZIP file contains your raw JSON data and a Backup PNG. These files are essential for recovering your account.\n\nUsage Instructions\n- Storage: You may delete the JSON file and keep only the PNG file if preferred.\n- Customization: You can rename the PNG file for added discretion.\n- Recovery: This PNG allows you to securely re-import your account without exposing raw JSON data.\n\nCritical Handling Requirements\nTo prevent data corruption and ensure the backup remains functional, please follow these rules:\n\n- No Modifications: Do not edit, crop, or apply filters to the PNG image.\n- No Processing: Avoid running the image through compression tools or \"optimization\" software.\n- Storage Only: Store the image as is. Do not upload it to social media, messaging apps, or cloud platforms that automatically compress or manipulate images, as this will destroy the embedded data.\n\nIMPORTANT: Keep this file secure and private at all times. If lost or compromised, your account access may be permanently at risk.";
|
|
13
|
+
/** README_TEXT plus a creation timestamp line — the only per-backup variable part. */
|
|
14
|
+
export declare function buildReadmeText(createdAt?: Date): string;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { MissingOptionalDependencyError } from "./error";
|
|
2
|
+
// ────────────────────────────────────────────────────────────────
|
|
3
|
+
// Lazy-loaded optional dependencies
|
|
4
|
+
// ────────────────────────────────────────────────────────────────
|
|
5
|
+
// jszip is only needed by toZIP()/fromZIP() — every other method on
|
|
6
|
+
// MajikKeyBackup (create, fromJSON, fromPNG, toJSON) never touches it,
|
|
7
|
+
// so no consumer pays for it unless they actually use the zip feature.
|
|
8
|
+
// Note: in bundled contexts (Vite/webpack/etc.) `import("jszip")` still
|
|
9
|
+
// needs jszip resolvable at build time — this buys code-splitting
|
|
10
|
+
// (its own lazy-fetched chunk), not "works with zero jszip installed".
|
|
11
|
+
// In plain-Node consumers (CLI, MCP server) it genuinely defers
|
|
12
|
+
// resolution to runtime, and throws the typed error below if absent.
|
|
13
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
14
|
+
let _jszip = null;
|
|
15
|
+
export async function getJSZip() {
|
|
16
|
+
if (_jszip)
|
|
17
|
+
return _jszip;
|
|
18
|
+
try {
|
|
19
|
+
const mod = await import("jszip");
|
|
20
|
+
_jszip = mod.default ?? mod;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
throw new MissingOptionalDependencyError("jszip", "read or write .zip backups");
|
|
24
|
+
}
|
|
25
|
+
return _jszip;
|
|
26
|
+
}
|
|
27
|
+
// @majikah/majik-bytes is a hard dependency of this library (Josef
|
|
28
|
+
// controls it), so this loader exists purely to keep the import
|
|
29
|
+
// pattern consistent with getJSZip() — not for optionality.
|
|
30
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
31
|
+
let _majikBytes = null;
|
|
32
|
+
export async function getMajikBytes() {
|
|
33
|
+
if (_majikBytes)
|
|
34
|
+
return _majikBytes;
|
|
35
|
+
_majikBytes = await import("@majikah/majik-bytes");
|
|
36
|
+
return _majikBytes;
|
|
37
|
+
}
|
|
38
|
+
// ────────────────────────────────────────────────────────────────
|
|
39
|
+
// Binary sniffing — classify by magic bytes, never by file extension
|
|
40
|
+
// ────────────────────────────────────────────────────────────────
|
|
41
|
+
const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
42
|
+
export function looksLikePNG(bytes) {
|
|
43
|
+
if (bytes.length < PNG_MAGIC.length)
|
|
44
|
+
return false;
|
|
45
|
+
return PNG_MAGIC.every((byte, i) => bytes[i] === byte);
|
|
46
|
+
}
|
|
47
|
+
/** Zip local-file-header signature ("PK\x03\x04"), also covers empty-archive/spanned variants starting "PK". */
|
|
48
|
+
export function looksLikeZip(bytes) {
|
|
49
|
+
return bytes.length > 2 && bytes[0] === 0x50 && bytes[1] === 0x4b;
|
|
50
|
+
}
|
|
51
|
+
// ────────────────────────────────────────────────────────────────
|
|
52
|
+
// Encoding helpers
|
|
53
|
+
// ────────────────────────────────────────────────────────────────
|
|
54
|
+
export function toSafeFileName(value) {
|
|
55
|
+
return value
|
|
56
|
+
.replace(/[<>:"/\\|?*\x00-\x1F]/g, "-")
|
|
57
|
+
.replace(/\s+/g, " ")
|
|
58
|
+
.trim()
|
|
59
|
+
.replace(/[. ]+$/, "");
|
|
60
|
+
}
|
|
61
|
+
// ────────────────────────────────────────────────────────────────
|
|
62
|
+
// README — centralized so copy updates happen in exactly one place
|
|
63
|
+
// ────────────────────────────────────────────────────────────────
|
|
64
|
+
/**
|
|
65
|
+
* Static body of the backup README. Update this whenever the copy
|
|
66
|
+
* needs to change — every zip generated by toZIP() picks it up
|
|
67
|
+
* automatically, nothing else needs to change.
|
|
68
|
+
*/
|
|
69
|
+
export const README_TEXT = `Majik Key Backup
|
|
70
|
+
|
|
71
|
+
IMPORTANT: Keep this file secure and private at all times. If lost or compromised, your account access may be permanently at risk.
|
|
72
|
+
|
|
73
|
+
Overview
|
|
74
|
+
This backup ZIP file contains your raw JSON data and a Backup PNG. These files are essential for recovering your account.
|
|
75
|
+
|
|
76
|
+
Usage Instructions
|
|
77
|
+
- Storage: You may delete the JSON file and keep only the PNG file if preferred.
|
|
78
|
+
- Customization: You can rename the PNG file for added discretion.
|
|
79
|
+
- Recovery: This PNG allows you to securely re-import your account without exposing raw JSON data.
|
|
80
|
+
|
|
81
|
+
Critical Handling Requirements
|
|
82
|
+
To prevent data corruption and ensure the backup remains functional, please follow these rules:
|
|
83
|
+
|
|
84
|
+
- No Modifications: Do not edit, crop, or apply filters to the PNG image.
|
|
85
|
+
- No Processing: Avoid running the image through compression tools or "optimization" software.
|
|
86
|
+
- Storage Only: Store the image as is. Do not upload it to social media, messaging apps, or cloud platforms that automatically compress or manipulate images, as this will destroy the embedded data.
|
|
87
|
+
|
|
88
|
+
IMPORTANT: Keep this file secure and private at all times. If lost or compromised, your account access may be permanently at risk.`;
|
|
89
|
+
/** README_TEXT plus a creation timestamp line — the only per-backup variable part. */
|
|
90
|
+
export function buildReadmeText(createdAt = new Date()) {
|
|
91
|
+
return `${README_TEXT}\n\nBackup created on: ${createdAt.toLocaleString()}\n`;
|
|
92
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { MnemonicJSON } from "../types";
|
|
2
|
+
/**
|
|
3
|
+
* Single source of truth for "is this a structurally valid
|
|
4
|
+
* MnemonicJSON". Called by `create`, `fromJSON`, `fromPNG` (post-decode),
|
|
5
|
+
* and `fromZIP` — never reimplemented at any entry point (DRY validation:
|
|
6
|
+
* one validator, many callers).
|
|
7
|
+
*
|
|
8
|
+
* Throws InvalidBackupJSONError with a specific reason on failure;
|
|
9
|
+
* narrows `input` to MnemonicJSON on success.
|
|
10
|
+
*/
|
|
11
|
+
export declare function validateMnemonicJSONShape(input: unknown): asserts input is MnemonicJSON;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { InvalidBackupJSONError } from "./error";
|
|
2
|
+
/**
|
|
3
|
+
* Single source of truth for "is this a structurally valid
|
|
4
|
+
* MnemonicJSON". Called by `create`, `fromJSON`, `fromPNG` (post-decode),
|
|
5
|
+
* and `fromZIP` — never reimplemented at any entry point (DRY validation:
|
|
6
|
+
* one validator, many callers).
|
|
7
|
+
*
|
|
8
|
+
* Throws InvalidBackupJSONError with a specific reason on failure;
|
|
9
|
+
* narrows `input` to MnemonicJSON on success.
|
|
10
|
+
*/
|
|
11
|
+
export function validateMnemonicJSONShape(input) {
|
|
12
|
+
if (typeof input !== "object" || input === null) {
|
|
13
|
+
throw new InvalidBackupJSONError("payload is not an object");
|
|
14
|
+
}
|
|
15
|
+
const candidate = input;
|
|
16
|
+
if (typeof candidate.id !== "string" || !candidate.id.trim()) {
|
|
17
|
+
throw new InvalidBackupJSONError("missing or empty `id`");
|
|
18
|
+
}
|
|
19
|
+
if (!Array.isArray(candidate.seed) || candidate.seed.length === 0) {
|
|
20
|
+
throw new InvalidBackupJSONError("missing or empty `seed`");
|
|
21
|
+
}
|
|
22
|
+
if (!candidate.seed.every((word) => typeof word === "string" && word.trim().length > 0)) {
|
|
23
|
+
throw new InvalidBackupJSONError("`seed` must be an array of non-empty words");
|
|
24
|
+
}
|
|
25
|
+
if (candidate.phrase !== undefined && typeof candidate.phrase !== "string") {
|
|
26
|
+
throw new InvalidBackupJSONError("`phrase` must be a string when present");
|
|
27
|
+
}
|
|
28
|
+
if (candidate.language !== undefined &&
|
|
29
|
+
typeof candidate.language !== "string") {
|
|
30
|
+
throw new InvalidBackupJSONError("`language` must be a string when present");
|
|
31
|
+
}
|
|
32
|
+
if (candidate.version !== undefined &&
|
|
33
|
+
typeof candidate.version !== "number") {
|
|
34
|
+
throw new InvalidBackupJSONError("`version` must be a number when present");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -108,7 +108,7 @@ async function _argon2id(input, salt, params) {
|
|
|
108
108
|
if (_wasmAvailable === null) {
|
|
109
109
|
_wasmAvailable = await _probeWasm();
|
|
110
110
|
if (!_wasmAvailable) {
|
|
111
|
-
console.warn("[majikah/crypto] hash-wasm unavailable, using @noble/hashes argon2id fallback");
|
|
111
|
+
console.warn("[@majikah/majik-key/crypto] hash-wasm unavailable, using @noble/hashes argon2id fallback");
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
114
|
if (_wasmAvailable) {
|
|
@@ -119,7 +119,7 @@ async function _argon2id(input, salt, params) {
|
|
|
119
119
|
// WASM loaded but failed at runtime (e.g. OOM, corrupted module)
|
|
120
120
|
// Flip flag so we stop trying for the rest of this session
|
|
121
121
|
_wasmAvailable = false;
|
|
122
|
-
console.warn("[majikah/majik-key/crypto] hash-wasm runtime failure, falling back to @noble/hashes", err);
|
|
122
|
+
console.warn("[@majikah/majik-key/crypto] hash-wasm runtime failure, falling back to @noble/hashes", err);
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
125
|
return _argon2idNoble(input, salt, params);
|
package/dist/core/types.d.ts
CHANGED
|
@@ -141,4 +141,6 @@ export interface MnemonicJSON {
|
|
|
141
141
|
/** Optional passphrase, carried in plaintext for convenience during export/import. ⚠️ Not encrypted. */
|
|
142
142
|
phrase?: string;
|
|
143
143
|
language?: MnemonicLanguage;
|
|
144
|
+
/** Backup format version this payload was written with. See BACKUP_FORMAT_VERSION. */
|
|
145
|
+
version?: number;
|
|
144
146
|
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/majik-key.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Seed phrase account library for the Majikah ecosystem.
|
|
4
4
|
*
|
|
5
5
|
*/
|
|
6
|
-
import { MajikContact, MajikContactMeta } from "@majikah/majik-contact";
|
|
6
|
+
import { MajikContact, MajikContactData, MajikContactMeta } from "@majikah/majik-contact";
|
|
7
7
|
import { KDF_VERSION } from "./core/crypto/constants";
|
|
8
8
|
import type { BitcoinRawPublicKey, ED25519RawPublicKey, MajikKeyAddress, MajikKeyDangerousJSON, MajikKeyFingerprint, MajikKeyJSON, MajikKeyMetadata, MLDSA87RawPublicKey, MLKEM768RawPublicKey, MnemonicJSON, X25519RawKey } from "./core/types";
|
|
9
9
|
import { MajikMessageIdentity } from "./core/database/system/identity";
|
|
@@ -361,6 +361,8 @@ export declare class MajikKey {
|
|
|
361
361
|
* You can pass a custom metadata type if needed, e.g., toContact<MyMeta>()
|
|
362
362
|
*/
|
|
363
363
|
toContact<TMeta extends MajikContactMeta = MajikContactMeta>(initialMeta?: Partial<TMeta>): MajikContact<TMeta>;
|
|
364
|
+
/** Build any MajikContact subclass by passing its constructor. */
|
|
365
|
+
toContact<TMeta extends MajikContactMeta, TContact extends MajikContact<TMeta>>(ContactClass: new (data: MajikContactData<TMeta>) => TContact, initialMeta?: Partial<TMeta>): TContact;
|
|
364
366
|
toKeyIdentity(): MajikKeyIdentity;
|
|
365
367
|
toSerializedIdentity(): SerializedIdentity;
|
|
366
368
|
toMajikMessageIdentity(user: MajikUser, options?: {
|
package/dist/majik-key.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { generateMnemonic as bip39GenerateMnemonic, mnemonicToSeed, validateMnemonic, } from "@scure/bip39";
|
|
7
7
|
import { aesGcmDecrypt, aesGcmEncrypt, deriveKeyFromPassphraseArgon2, deriveKeyFromMnemonicArgon2, deriveKeyFromPassphrase, generateRandomBytes, IV_LENGTH, } from "./core/crypto/crypto-provider";
|
|
8
8
|
import { EncryptionEngine } from "./core/crypto/encryption-engine";
|
|
9
|
-
import { MajikContact } from "@majikah/majik-contact";
|
|
9
|
+
import { MajikContact, } from "@majikah/majik-contact";
|
|
10
10
|
import { arrayBufferToBase64, arrayToBase64, base64ToArrayBuffer, concatUint8Arrays, utf8ToBase64, base64ToUtf8, seedStringToArray, seedArrayToString, base64ToUint8Array, } from "./core/utils";
|
|
11
11
|
import { KDF_VERSION, MAJIK_MNEMONIC_SALT } from "./core/crypto/constants";
|
|
12
12
|
import { MajikKeyValidator } from "./core/validator";
|
|
@@ -801,23 +801,15 @@ export class MajikKey {
|
|
|
801
801
|
return false;
|
|
802
802
|
}
|
|
803
803
|
}
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
toContact(initialMeta) {
|
|
809
|
-
const mlKeyBase64 = arrayToBase64(this.mlKemPublicKey);
|
|
810
|
-
// We construct the base metadata and merge with any provided initialMeta
|
|
811
|
-
const meta = {
|
|
812
|
-
label: this._label,
|
|
813
|
-
...initialMeta,
|
|
814
|
-
};
|
|
815
|
-
return new MajikContact({
|
|
804
|
+
toContact(arg1, arg2) {
|
|
805
|
+
const ContactClass = (typeof arg1 === "function" ? arg1 : MajikContact);
|
|
806
|
+
const initialMeta = (typeof arg1 === "function" ? arg2 : arg1);
|
|
807
|
+
return new ContactClass({
|
|
816
808
|
id: this._id,
|
|
817
809
|
publicKey: this._publicKey,
|
|
818
810
|
fingerprint: this._fingerprint,
|
|
819
|
-
meta:
|
|
820
|
-
mlKey:
|
|
811
|
+
meta: { label: this._label, ...initialMeta },
|
|
812
|
+
mlKey: arrayToBase64(this.mlKemPublicKey),
|
|
821
813
|
edPublicKeyBase64: this._edPublicKey
|
|
822
814
|
? arrayToBase64(this._edPublicKey)
|
|
823
815
|
: undefined,
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@majikah/majik-key",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"description": "A post-quantum ready seed phrase account library for the Majikah ecosystem. Manages deterministic X25519 and ML-KEM-768 identities with Argon2id protection and seamless legacy account migration.",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.7.0",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"author": "Zelijah",
|
|
8
8
|
"main": "./dist/index.js",
|
|
@@ -48,14 +48,15 @@
|
|
|
48
48
|
"package": "npm run build && npm version patch && git push && git push --tags",
|
|
49
49
|
"test": "vitest run",
|
|
50
50
|
"test:watch": "vitest",
|
|
51
|
-
"test:compat": "
|
|
52
|
-
"test:web3:bitcoin": "
|
|
53
|
-
"test:web3:solana": "
|
|
54
|
-
"test:web3": "
|
|
55
|
-
"test:core": "
|
|
51
|
+
"test:compat": "vitest run test/majik-key-cross-compat.test.ts",
|
|
52
|
+
"test:web3:bitcoin": "vitest run test/majik-key-bitcoin.test.ts",
|
|
53
|
+
"test:web3:solana": "vitest run test/majik-key-solana.test.ts",
|
|
54
|
+
"test:web3": "vitest run test/majik-key-bitcoin.test.ts test/majik-key-solana.test.ts",
|
|
55
|
+
"test:core": "vitest run test/majik-key.test.ts",
|
|
56
|
+
"test:backup": "vitest run test/backup/majik-key-backup.test.ts"
|
|
56
57
|
},
|
|
57
58
|
"dependencies": {
|
|
58
|
-
"@majikah/majik-contact": "^0.
|
|
59
|
+
"@majikah/majik-contact": "^0.1.1",
|
|
59
60
|
"@noble/curves": "^2.4.0",
|
|
60
61
|
"@noble/hashes": "^2.4.0",
|
|
61
62
|
"@noble/post-quantum": "^0.7.1",
|
|
@@ -72,16 +73,20 @@
|
|
|
72
73
|
"hash-wasm": "^4.12.0"
|
|
73
74
|
},
|
|
74
75
|
"devDependencies": {
|
|
76
|
+
"@majikah/majik-bytes": "^1.0.0",
|
|
75
77
|
"@scure/btc-signer": "^2.4.1",
|
|
76
78
|
"@solana/kit": "^8.3.0",
|
|
77
79
|
"@types/ed2curve": "^0.2.4",
|
|
78
80
|
"@types/node": "^26.6.2",
|
|
81
|
+
"jszip": "^3.10.2",
|
|
79
82
|
"typescript": "^7.0.2",
|
|
80
83
|
"vitest": "^5.0.1"
|
|
81
84
|
},
|
|
82
85
|
"peerDependencies": {
|
|
83
|
-
"@
|
|
84
|
-
"@
|
|
86
|
+
"@majikah/majik-bytes": ">=1.0.0",
|
|
87
|
+
"@scure/btc-signer": ">=2.4.1",
|
|
88
|
+
"@solana/kit": ">=8.3.0",
|
|
89
|
+
"jszip": ">=3.10.2"
|
|
85
90
|
},
|
|
86
91
|
"peerDependenciesMeta": {
|
|
87
92
|
"@solana/kit": {
|
|
@@ -89,6 +94,12 @@
|
|
|
89
94
|
},
|
|
90
95
|
"@scure/btc-signer": {
|
|
91
96
|
"optional": true
|
|
97
|
+
},
|
|
98
|
+
"jszip": {
|
|
99
|
+
"optional": true
|
|
100
|
+
},
|
|
101
|
+
"@majikah/majik-bytes": {
|
|
102
|
+
"optional": true
|
|
92
103
|
}
|
|
93
104
|
}
|
|
94
105
|
}
|