@supersuit/suit-identity 0.0.0-stage → 0.1.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/CHANGELOG.md +5 -0
- package/LICENSE +21 -0
- package/README.md +41 -2
- package/bin/suit-identity.mjs +26 -0
- package/package.json +30 -4
- package/src/index.mjs +109 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
First release, extracted on 2026-10-17 from two copies: Supersuit's `freedom-suit-id.mjs` (the Mac that mints and signs) and the Supersuit account server's `suit-identity.mjs` (the checker). Suit IDs from a first Ed25519 key, the signed hash-chained key log (genesis, add-key, retire-key) and its replay, canonical JSON, the handle rule (8 to 32, no exceptions, reserved names), and a CLI (`id`, `verify`, `check-handle`). Pinned by the test vector both callers assert (seed 32 x 0x09).
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SupersuitUp
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,42 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @supersuit/suit-identity
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Durable, key-based identities. A suit's ID is derived from its first Ed25519 key and never changes. Every later key change (a second device added, a lost one retired) is an entry in a signed, hash-chained log that anyone can replay to learn which keys are active, without trusting whoever handed them the log. A handle is the changeable human name that points at an ID.
|
|
4
|
+
|
|
5
|
+
Zero dependencies (Node's own crypto), no file or network access: you keep the keys and the log wherever you like.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @supersuit/suit-identity
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Library
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
import { newDeviceKey, genesisEntry, nextEntry, verifyLog, signEntry, verifySig, checkHandle, suitIdFrom } from "@supersuit/suit-identity";
|
|
15
|
+
|
|
16
|
+
const mac = newDeviceKey(); // { priv, pub }; keep priv private
|
|
17
|
+
const log = [genesisEntry({ priv: mac.priv, device: "mac", at: new Date().toISOString() })];
|
|
18
|
+
const phone = newDeviceKey();
|
|
19
|
+
log.push(nextEntry(log, { op: "add-key", key: phone.pub, device: "phone", at: new Date().toISOString() }, mac.priv));
|
|
20
|
+
|
|
21
|
+
verifyLog(log); // { ok: true, suit: "ss1...", keys: [mac.pub, phone.pub] }
|
|
22
|
+
checkHandle("garysheng"); // { ok: true }
|
|
23
|
+
checkHandle("gary"); // { ok: false, why: "a handle is at least 8 characters, with no exceptions" }
|
|
24
|
+
|
|
25
|
+
const claim = signEntry({ op: "claim", handle: "garysheng", suit: log[0].suit, at: new Date().toISOString() }, mac.priv);
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Options, for another system: `suitIdFrom(pub, { prefix, length })` (default `ss1` and 16 characters), `verifyLog(log, { idOptions })`, `checkHandle(h, { min, max, reserved })` (default 8, 32 and Supersuit's reserved names).
|
|
29
|
+
|
|
30
|
+
## CLI
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
suit-identity id <public-key> # the suit ID that key founds
|
|
34
|
+
suit-identity verify <log.jsonl|-> # replay a key log, one JSON entry per line
|
|
35
|
+
suit-identity check-handle <handle>
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Add `--json` for machine output. Exit codes: 0 ok, 1 refused, 2 usage.
|
|
39
|
+
|
|
40
|
+
## Who uses it
|
|
41
|
+
|
|
42
|
+
Supersuit's Macs mint and sign with it (`freedom-suit-id.mjs`), and the Supersuit account server checks every suit and name claim with it. The test vector in `test/` (seed 32 x 0x09) is the exact bytes both agree on.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// suit-identity: check suit IDs, key logs and handles from the command line.
|
|
3
|
+
// suit-identity id <public-key> the suit ID that key founds
|
|
4
|
+
// suit-identity verify <log.jsonl|-> replay a key log (one JSON entry per line); prints the active keys
|
|
5
|
+
// suit-identity check-handle <handle> is this a valid handle
|
|
6
|
+
// Add --json for machine output. Exit codes: 0 ok, 1 refused, 2 usage.
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { checkHandle, suitIdFrom, verifyLog } from "../src/index.mjs";
|
|
9
|
+
|
|
10
|
+
const [cmd, arg, ...rest] = process.argv.slice(2);
|
|
11
|
+
const json = [arg, ...rest].includes("--json");
|
|
12
|
+
const say = (obj, line) => console.log(json ? JSON.stringify(obj) : line);
|
|
13
|
+
const usage = () => { console.error("usage: suit-identity id <public-key> | verify <log.jsonl|-> | check-handle <handle> [--json]"); process.exit(2); };
|
|
14
|
+
|
|
15
|
+
if (!arg || arg === "--json") usage();
|
|
16
|
+
if (cmd === "id") { const suit = suitIdFrom(arg); say({ suit }, suit); process.exit(0); }
|
|
17
|
+
if (cmd === "verify") {
|
|
18
|
+
let log;
|
|
19
|
+
try { log = readFileSync(arg === "-" ? 0 : arg, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l)); }
|
|
20
|
+
catch (e) { say({ ok: false, why: `could not read the log: ${e.message}` }, `could not read the log: ${e.message}`); process.exit(1); }
|
|
21
|
+
const v = verifyLog(log);
|
|
22
|
+
say(v, v.ok ? `${v.suit}: verifies, ${v.keys.length} active key${v.keys.length === 1 ? "" : "s"}\n${v.keys.join("\n")}` : `does not verify: ${v.why}`);
|
|
23
|
+
process.exit(v.ok ? 0 : 1);
|
|
24
|
+
}
|
|
25
|
+
if (cmd === "check-handle") { const r = checkHandle(arg); say(r, r.ok ? `${arg} is a valid handle` : r.why); process.exit(r.ok ? 0 : 1); }
|
|
26
|
+
usage();
|
package/package.json
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersuit/suit-identity",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Durable, key-based identities for Supersuit suits: a suit ID derived from its first key, a signed key log anyone can replay, and the handle rule.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"suit-identity": "bin/suit-identity.mjs"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.mjs",
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin/",
|
|
15
|
+
"src/",
|
|
16
|
+
"README.md",
|
|
17
|
+
"CHANGELOG.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "node --test test/*.test.mjs"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=20"
|
|
25
|
+
},
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/SupersuitUp/suit-identity.git"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {}
|
|
32
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// @supersuit/suit-identity: durable, key-based identities. A suit's ID is derived from its first
|
|
2
|
+
// Ed25519 key and never changes; every later key change is an entry in a signed, hash-chained log that
|
|
3
|
+
// anyone can replay to learn which keys are active, without trusting whoever handed them the log.
|
|
4
|
+
// A handle is the changeable, human name that points at an ID.
|
|
5
|
+
//
|
|
6
|
+
// One copy of this logic for every caller: the Mac that mints and signs (Supersuit's
|
|
7
|
+
// freedom-suit-id.mjs) and the server that checks (the Supersuit account server). Zero dependencies;
|
|
8
|
+
// Node's own crypto. Pure: no file or network access, so each caller keeps its own storage.
|
|
9
|
+
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from "node:crypto";
|
|
10
|
+
|
|
11
|
+
/** JSON with object keys sorted at every depth, no whitespace, undefined fields dropped: the bytes
|
|
12
|
+
* every signature covers, so the same payload signs the same way on every machine. */
|
|
13
|
+
export function canonical(value) {
|
|
14
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
15
|
+
if (Array.isArray(value)) return "[" + value.map((v) => (v === undefined ? "null" : canonical(v))).join(",") + "]";
|
|
16
|
+
return "{" + Object.keys(value).filter((k) => value[k] !== undefined).sort().map((k) => JSON.stringify(k) + ":" + canonical(value[k])).join(",") + "}";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const privObj = (priv) => createPrivateKey({ key: Buffer.from(priv, "base64url"), format: "der", type: "pkcs8" });
|
|
20
|
+
const pubObj = (pub) => createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x: pub }, format: "jwk" });
|
|
21
|
+
|
|
22
|
+
/** A new Ed25519 key. `priv` is pkcs8 DER, base64url (keep it private); `pub` is the raw key, base64url. */
|
|
23
|
+
export function newDeviceKey() {
|
|
24
|
+
const { privateKey } = generateKeyPairSync("ed25519");
|
|
25
|
+
const priv = privateKey.export({ format: "der", type: "pkcs8" }).toString("base64url");
|
|
26
|
+
return { priv, pub: createPublicKey(privObj(priv)).export({ format: "jwk" }).x };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The public half of a pkcs8 base64url private key. */
|
|
30
|
+
export const publicKeyOf = (priv) => createPublicKey(privObj(priv)).export({ format: "jwk" }).x;
|
|
31
|
+
|
|
32
|
+
// Crockford base32, lowercase: no i, l, o or u, so an ID read aloud or copied by hand survives.
|
|
33
|
+
const CROCKFORD = "0123456789abcdefghjkmnpqrstvwxyz";
|
|
34
|
+
function base32(bytes) {
|
|
35
|
+
let bits = 0, value = 0, out = "";
|
|
36
|
+
for (const b of bytes) {
|
|
37
|
+
value = (value << 8) | b; bits += 8;
|
|
38
|
+
while (bits >= 5) { out += CROCKFORD[(value >>> (bits - 5)) & 31]; bits -= 5; }
|
|
39
|
+
}
|
|
40
|
+
if (bits > 0) out += CROCKFORD[(value << (5 - bits)) & 31];
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The durable ID of the suit whose first key is `pub`: a prefix plus `length` Crockford base32
|
|
45
|
+
* characters of the key's sha256 (default "ss1" + 16, which is 80 bits). */
|
|
46
|
+
export const suitIdFrom = (pub, { prefix = "ss1", length = 16 } = {}) =>
|
|
47
|
+
prefix + base32(createHash("sha256").update(Buffer.from(pub, "base64url")).digest()).slice(0, length);
|
|
48
|
+
|
|
49
|
+
/** `payload` with a `sig` field: Ed25519 over its canonical bytes. */
|
|
50
|
+
export const signEntry = (payload, priv) => ({ ...payload, sig: sign(null, Buffer.from(canonical(payload)), privObj(priv)).toString("base64url") });
|
|
51
|
+
|
|
52
|
+
/** Does `sig` verify over the canonical bytes of `payload` with public key `pub`? Never throws. */
|
|
53
|
+
export function verifySig(payload, sig, pub) {
|
|
54
|
+
try { return verify(null, Buffer.from(canonical(payload)), pubObj(pub), Buffer.from(String(sig ?? ""), "base64url")); } catch { return false; }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const entryHash = (e) => createHash("sha256").update(canonical(e)).digest("hex");
|
|
58
|
+
const bodyOf = (e) => { const { sig, ...rest } = e; return rest; };
|
|
59
|
+
|
|
60
|
+
/** The genesis entry of a new suit, self-signed by its first key. */
|
|
61
|
+
export function genesisEntry({ priv, device, at, idOptions } = {}) {
|
|
62
|
+
const key = publicKeyOf(priv);
|
|
63
|
+
return signEntry({ v: 1, op: "genesis", suit: suitIdFrom(key, idOptions), key, device, at, prev: null }, priv);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The next log entry after `log` (op "add-key" or "retire-key"), chained to its last entry and
|
|
67
|
+
* signed by `priv`, which must be a key still active in the log for the entry to verify. */
|
|
68
|
+
export function nextEntry(log, { op, key, device, at }, priv) {
|
|
69
|
+
return signEntry({ v: 1, op, suit: log[0].suit, key, device, at, prev: entryHash(log.at(-1)) }, priv);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Replay a log. ok only when the genesis is self-signed by the key its suit ID derives from, and
|
|
73
|
+
* every later entry is chained to the one before and signed by a key active at that point. Returns
|
|
74
|
+
* { ok: true, suit, keys } (the keys active at the end) or { ok: false, why }. */
|
|
75
|
+
export function verifyLog(log, { idOptions } = {}) {
|
|
76
|
+
const no = (why) => ({ ok: false, why });
|
|
77
|
+
if (!Array.isArray(log) || !log.length) return no("the log is empty");
|
|
78
|
+
const g = log[0];
|
|
79
|
+
if (g?.op !== "genesis" || g.prev !== null) return no("the first entry is not a genesis");
|
|
80
|
+
if (suitIdFrom(g.key, idOptions) !== g.suit) return no("the genesis suit is not the one its key derives");
|
|
81
|
+
if (!verifySig(bodyOf(g), g.sig, g.key)) return no("the genesis signature does not verify");
|
|
82
|
+
const active = new Set([g.key]);
|
|
83
|
+
for (let i = 1; i < log.length; i++) {
|
|
84
|
+
const e = log[i];
|
|
85
|
+
if (e.suit !== g.suit) return no(`entry ${i} names another suit`);
|
|
86
|
+
if (e.prev !== entryHash(log[i - 1])) return no(`entry ${i} is not chained to entry ${i - 1}`);
|
|
87
|
+
if (![...active].some((k) => verifySig(bodyOf(e), e.sig, k))) return no(`entry ${i} is not signed by an active key`);
|
|
88
|
+
if (e.op === "add-key") active.add(e.key);
|
|
89
|
+
else if (e.op === "retire-key") { active.delete(e.key); if (!active.size) return no(`entry ${i} retires the last key`); }
|
|
90
|
+
else return no(`entry ${i} has an unknown op ${e.op}`);
|
|
91
|
+
}
|
|
92
|
+
return { ok: true, suit: g.suit, keys: [...active] };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Names that read as the operator of the system speaking. Only names at or above the minimum length
|
|
96
|
+
* need listing. Supersuit's set; pass your own as `reserved`. */
|
|
97
|
+
export const RESERVED = Object.freeze(["supersuit", "supersuits", "continental", "continentalworks", "administrator", "postmaster", "hostmaster", "webmaster", "official", "security", "helpdesk", "freedomapp", "multiversal", "cofounders"]);
|
|
98
|
+
|
|
99
|
+
/** The handle rule. Defaults are Supersuit's: 8 to 32 characters with no exceptions (decided
|
|
100
|
+
* 2026-10-17), lowercase letters, digits and single hyphens, reserved names refused. */
|
|
101
|
+
export function checkHandle(h, { min = 8, max = 32, reserved = RESERVED } = {}) {
|
|
102
|
+
const no = (why) => ({ ok: false, why });
|
|
103
|
+
if (typeof h !== "string") return no("a handle is text");
|
|
104
|
+
if (h.length < min) return no(`a handle is at least ${min} characters, with no exceptions`);
|
|
105
|
+
if (h.length > max) return no(`a handle is at most ${max} characters`);
|
|
106
|
+
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(h)) return no("a handle is lowercase letters, digits and single hyphens, starting and ending with a letter or digit");
|
|
107
|
+
if (reserved.includes(h)) return no(`${h} is reserved`);
|
|
108
|
+
return { ok: true };
|
|
109
|
+
}
|