pyrtan / sourcev0.19.0 · main

identity.js: the documents a root signs, and encrypted backups, agreeing with Python

Claude · 2026-10-28 · 3d44363e23268dee53d94710f1ec9965ce1d0c52 · parents 38f14f2

makeIdentity, makeDevice, withDevice, withRevoked, makeName (with a handover), makeRelease and signDocument; createRoot (extractable once, to be backed up), makeBackup, openBackup and rootFromSeed, by crypto.subtle (PBKDF2-SHA256, AES-256-GCM, Ed25519). The vectors now hold signed documents and a backup: identity.js signs the same bytes, writes the same backup and opens Python's. A device note's abilities are in the canonical order in both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rD7jgoLgxA3qCSMVWdoDv

Changes

 src/pyrtan/identity.py                     |   2 +-
 src/pyrtan/js/identity.js                  | 119 ++++++++++++++++++++++++++++-
 tests/conformance/identity.cjs             |  18 +++++
 tests/conformance/identity.json            | 202 +++++++++++++++++++++++++++++++++++++++++++++++++
 tests/conformance/make_identity_vectors.py |  17 +++++
 5 files changed, 354 insertions(+), 4 deletions(-)
diff --git a/src/pyrtan/identity.py b/src/pyrtan/identity.py
index 80bb54e..59922cb 100644
--- a/src/pyrtan/identity.py
+++ b/src/pyrtan/identity.py
@@ -325,7 +325,7 @@ def make_device(root: Key, device: str, may: list[str], *, label: str = "", issu
issued = int(time.time()) if issued is None else issued note = { "pyrtan-device": 1, "root": root.id, "device": normal(device), "label": label,
- "may": _abilities(may), "issued": issued, "expires": issued + YEAR if expires is None else expires,
+ "may": [x for x in ABILITIES if x in _abilities(may)], "issued": issued, "expires": issued + YEAR if expires is None else expires,
} if note["device"] == root.id: raise IdentityError("a root does not delegate to itself; it signs for itself") diff --git a/src/pyrtan/js/identity.js b/src/pyrtan/js/identity.js index 28084fe..5f22b41 100644 --- a/src/pyrtan/js/identity.js +++ b/src/pyrtan/js/identity.js
@@ -3,8 +3,11 @@
* What the console needs of _meta/identity.md, and what Python's * src/pyrtan/identity.py does the same way: identifiers (`pyr:` and the * base32 of key, SHA3-256 checksum and version), canonical JSON, the
- * message a request signature is over, and signing it with a device key.
- * tests/conformance/identity.json holds the vectors both must agree on.
+ * message a request signature is over, and signing it; the documents a
+ * root signs (identity documents, device notes, name claims, releases,
+ * handovers); and the encrypted backup of a root key (PBKDF2-SHA256, then
+ * AES-256-GCM, all by crypto.subtle). tests/conformance/identity.json
+ * holds the vectors both must agree on.
* * SHA-256 and SHA3-256 are written out here: crypto.subtle has no SHA3, * and none of crypto.subtle on a plain http:// LAN address. Ed25519 is
@@ -21,6 +24,9 @@
"use strict"; const PREFIX = "pyr:", ID_LENGTH = 56, VERSION_ED25519 = 1, REQUEST_TAG = "pyrtan-req-v1";
+ const DOC_TAG = "pyrtan-doc-v1\n", HANDOVER_TAG = "pyrtan-handover-v1\n", BACKUP_TAG = "pyrtan-key-backup-v1\n";
+ const ABILITIES = ["ask", "chat", "run", "operate"], YEAR = 365 * 24 * 3600, BACKUP_ITERATIONS = 600000, MIN_ITERATIONS = 100000;
+ const LOCAL = /^[a-z0-9][a-z0-9._-]{0,62}$/, LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
const enc = new TextEncoder(); const hex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); const concat = (...parts) => { const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); let o = 0; for (const p of parts) { out.set(p, o); o += p.length; } return out; };
@@ -167,6 +173,108 @@
return null; }
+ // ------------------------------------------------------------ documents a root signs
+
+ const now = () => Math.floor(Date.now() / 1000);
+ const without = (doc, ...keys) => Object.fromEntries(Object.entries(doc).filter(([k]) => !keys.includes(k)));
+ async function sign(key, bytes) { return new Uint8Array(await subtle().sign({ name: "Ed25519" }, key.privateKey, bytes)); }
+ async function signDocument(key, doc) {
+ const body = without(doc, "sig");
+ return Object.assign({}, body, { sig: b64url(await sign(key, enc.encode(DOC_TAG + canonical(body)))) });
+ }
+ const abilities = (may) => { const bad = may.filter((a) => !ABILITIES.includes(a)); if (bad.length || new Set(may).size !== may.length) throw new Error(`may is some of ${ABILITIES.join(", ")}, each once`); return ABILITIES.filter((a) => may.includes(a)); };
+ function host(text) {
+ const h = String(text || "").toLowerCase(), labels = h.split(".");
+ if (h.length > 253 || !labels.every((l) => LABEL.test(l))) throw new Error(`${JSON.stringify(text)} is not a host name`);
+ if (labels[labels.length - 1] === "onion" && (labels.length < 2 || labels[labels.length - 2].length !== 56)) throw new Error("an onion address is 56 characters before .onion");
+ return h;
+ }
+ function parseName(text) {
+ const t = String(text || "").trim().toLowerCase(), at = t.lastIndexOf("@");
+ if (at < 1 || t.startsWith(PREFIX)) throw new Error(`${JSON.stringify(text)} is not a name (name@domain)`);
+ const name = t.slice(0, at);
+ if (!LOCAL.test(name)) throw new Error(`${JSON.stringify(name)} is not lowercase letters, digits, dot, dash, underscore, at most 63`);
+ return { name, domain: host(t.slice(at + 1)) };
+ }
+ async function makeDevice(root, device, may, opts) {
+ const o = opts || {}, issued = o.issued !== undefined ? o.issued : now();
+ decode(device);
+ if (device.toLowerCase() === root.id) throw new Error("a root does not delegate to itself; it signs for itself");
+ return signDocument(root, { "pyrtan-device": 1, root: root.id, device: device.toLowerCase(), label: o.label || "", may: abilities(may), issued, expires: o.expires !== undefined ? o.expires : issued + YEAR });
+ }
+ async function makeIdentity(root, opts) {
+ const o = opts || {};
+ return signDocument(root, { "pyrtan-identity": 1, id: root.id, kind: o.kind || "person", routes: o.routes || [], devices: o.devices || [], revoked: o.revoked || [], updated: o.updated !== undefined ? o.updated : now() });
+ }
+ // A new document, strictly newer than the one it replaces, or a holder would keep the old one.
+ const reissue = (doc, root, changes) => makeIdentity(root, Object.assign({ kind: doc.kind, routes: doc.routes, devices: doc.devices, revoked: doc.revoked }, changes, { updated: Math.max(now(), doc.updated + 1) }));
+ const withDevice = (doc, root, note) => reissue(doc, root, { devices: doc.devices.filter((n) => n.device !== note.device).concat([note]), revoked: doc.revoked.filter((r) => r !== note.device) });
+ const withRevoked = (doc, root, device) => reissue(doc, root, { devices: doc.devices.filter((n) => n.device !== device), revoked: doc.revoked.includes(device) ? doc.revoked : doc.revoked.concat([device]) });
+ async function makeName(key, name, opts) {
+ const o = opts || {}, n = parseName(name);
+ const claim = Object.assign({ "pyrtan-name": 1, name: n.name, domain: n.domain, id: key.id, issued: o.issued !== undefined ? o.issued : now() }, o.extra || {});
+ if (o.handoverFrom) claim.handover = { id: o.handoverFrom.id, sig: b64url(await sign(o.handoverFrom, enc.encode(HANDOVER_TAG + canonical(claim)))) };
+ return signDocument(key, claim);
+ }
+ async function makeRelease(key, name, opts) {
+ const o = opts || {}, n = parseName(name);
+ return signDocument(key, { "pyrtan-name-release": 1, name: n.name, domain: n.domain, id: key.id, issued: o.issued !== undefined ? o.issued : now() });
+ }
+
+ // ------------------------------------------------------------ roots and their backups
+ //
+ // A root made in a page is extractable exactly once: its seed is read out to
+ // be encrypted into the backup, then the key is imported again,
+ // non-extractable, and the seed is wiped. Restoring decrypts the seed and
+ // imports it non-extractable the same way.
+
+ async function createRoot() {
+ const pair = await subtle().generateKey({ name: "Ed25519" }, true, ["sign", "verify"]);
+ const jwk = await subtle().exportKey("jwk", pair.privateKey);
+ const seed = unb64url(jwk.d), publicKey = unb64url(jwk.x);
+ return { id: encode(publicKey), seed, publicKey };
+ }
+ async function backupKey(passphrase, salt, iterations) {
+ const base = await subtle().importKey("raw", enc.encode(passphrase), "PBKDF2", false, ["deriveKey"]);
+ return subtle().deriveKey({ name: "PBKDF2", hash: "SHA-256", salt, iterations }, base, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
+ }
+ async function makeBackup(seed, id, passphrase, opts) {
+ const o = opts || {};
+ if (String(passphrase).length < 8) throw new Error("a passphrase is at least 8 characters; a long sentence is better");
+ const salt = o.salt || crypto.getRandomValues(new Uint8Array(16)), iv = o.iv || crypto.getRandomValues(new Uint8Array(12)), iterations = o.iterations || BACKUP_ITERATIONS;
+ const data = new Uint8Array(await subtle().encrypt({ name: "AES-GCM", iv, additionalData: enc.encode(BACKUP_TAG + id) }, await backupKey(passphrase, salt, iterations), seed));
+ const backup = { "pyrtan-key-backup": 1, id, made: o.made !== undefined ? o.made : now(), kdf: { name: "PBKDF2-SHA256", iterations, salt: b64url(salt) }, cipher: { name: "AES-256-GCM", iv: b64url(iv) }, data: b64url(data) };
+ if (o.document) backup.document = o.document;
+ return backup;
+ }
+ function backupProblem(b) {
+ if (!b || typeof b !== "object" || b["pyrtan-key-backup"] !== 1) return "not a key backup (pyrtan-key-backup: 1)";
+ if (!valid(b.id)) return "the backup names no identity";
+ if (!b.kdf || b.kdf.name !== "PBKDF2-SHA256" || !(b.kdf.iterations >= MIN_ITERATIONS && b.kdf.iterations <= 10000000) || typeof b.kdf.salt !== "string") return `the backup's kdf is not PBKDF2-SHA256 with at least ${MIN_ITERATIONS} iterations`;
+ if (!b.cipher || b.cipher.name !== "AES-256-GCM" || typeof b.cipher.iv !== "string" || typeof b.data !== "string") return "the backup's cipher is not AES-256-GCM with an iv and data";
+ if (b.document && b.document.id !== b.id.toLowerCase()) return "the document beside the key is another identity's";
+ return null;
+ }
+ /** The seed in a backup; throws for a wrong passphrase or a damaged file. */
+ async function openBackup(b, passphrase) {
+ const bad = backupProblem(b);
+ if (bad) throw new Error(bad);
+ let seed;
+ try {
+ seed = new Uint8Array(await subtle().decrypt({ name: "AES-GCM", iv: unb64url(b.cipher.iv), additionalData: enc.encode(BACKUP_TAG + b.id.toLowerCase()) }, await backupKey(passphrase, unb64url(b.kdf.salt), b.kdf.iterations), unb64url(b.data)));
+ } catch (e) { throw new Error("the passphrase does not open this backup (or the file is damaged)"); }
+ return seed;
+ }
+ /** A non-extractable key from a seed, checked against the identity it should be. */
+ async function rootFromSeed(seed, id) {
+ const key = await fromSeedAndPublic(seed, decode(id));
+ const probe = enc.encode("pyrtan-probe"), sig = await sign(key, probe);
+ const pub = await subtle().importKey("raw", decode(id), { name: "Ed25519" }, false, ["verify"]);
+ if (!(await subtle().verify({ name: "Ed25519" }, pub, sig, probe))) throw new Error("the backup's key is not the identity it names");
+ seed.fill(0);
+ return key;
+ }
+
// ------------------------------------------------------------ the device key, in IndexedDB const store = {
@@ -180,5 +288,10 @@
async put(v) { const db = await this.open(); return new Promise((res, rej) => { const t = db.transaction("keys", "readwrite"); if (v) t.objectStore("keys").put(v, "device"); else t.objectStore("keys").delete("device"); t.oncomplete = () => res(); t.onerror = () => rej(t.error); }); }, };
- return { PREFIX, sha256, sha3_256, hex, b64url, unb64url, encode, decode, valid, short, canonical, requestMessage, available, generate, fromSeedAndPublic, signRequest, noteProblem, store };
+ return {
+ PREFIX, ABILITIES, sha256, sha3_256, hex, b64url, unb64url, encode, decode, valid, short, canonical, parseName, requestMessage,
+ available, generate, fromSeedAndPublic, signRequest, noteProblem, store,
+ signDocument, makeDevice, makeIdentity, withDevice, withRevoked, makeName, makeRelease,
+ createRoot, makeBackup, backupProblem, openBackup, rootFromSeed,
+ };
}); diff --git a/tests/conformance/identity.cjs b/tests/conformance/identity.cjs index 3ddb431..f219d0a 100644 --- a/tests/conformance/identity.cjs +++ b/tests/conformance/identity.cjs
@@ -33,6 +33,24 @@ const bytes = (h) => Uint8Array.from(h.match(/../g) || [], (x) => parseInt(x, 16
const key = await Id.fromSeedAndPublic(bytes(r.seed), bytes(r.public)); check(`headers ${label}`, r.headers, await Id.signRequest(key, r.audience, r.method, r.path, r.body, r.note, r.date)); }
+ for (const d of v.documents) {
+ const key = await Id.fromSeedAndPublic(bytes(d.seed), bytes(d.public)), m = d.make;
+ let got;
+ if (d.kind === "identity") got = await Id.makeIdentity(key, m);
+ else if (d.kind === "device") got = await Id.makeDevice(key, m.device, m.may, m);
+ else if (d.kind === "name") got = await Id.makeName(key, m.name, m);
+ else if (d.kind === "handover") got = await Id.makeName(key, m.name, Object.assign({}, m, { handoverFrom: await Id.fromSeedAndPublic(bytes(d.from_seed), bytes(d.from_public)) }));
+ else if (d.kind === "release") got = await Id.makeRelease(key, m.name, m);
+ check(`signs a ${d.kind}`, d.expect, got);
+ }
+ for (const b of v.backups) {
+ const seed = await Id.openBackup(b.expect, b.passphrase);
+ check("opens a backup Python wrote", b.seed, Id.hex(seed));
+ check("writes the backup Python writes", b.expect, await Id.makeBackup(bytes(b.seed), b.expect.id, b.passphrase, { salt: bytes(b.salt), iv: bytes(b.iv), iterations: b.iterations, made: b.made, document: b.expect.document }));
+ let refused = null; try { await Id.openBackup(b.expect, b.passphrase + "x"); } catch (e) { refused = e.message; }
+ check("refuses a wrong passphrase", true, /does not open/.test(refused || ""));
+ check("refuses a weakened kdf", true, /at least/.test(Id.backupProblem(Object.assign({}, b.expect, { kdf: Object.assign({}, b.expect.kdf, { iterations: 1000 }) })) || ""));
+ }
console.log(`${total - failed}/${total} agree`); process.exit(failed ? 1 : 0); })(); diff --git a/tests/conformance/identity.json b/tests/conformance/identity.json index d24e299..6220912 100644 --- a/tests/conformance/identity.json +++ b/tests/conformance/identity.json
@@ -3,6 +3,60 @@
"pyr:h3rkrjzihszp24ujipnkcj7pbhsigby2rnf4ngn2iurpbgyuz7pgyoyb", "PYR:H3RKRJZIHSZP24UJIPNKCJ7PBHSIGBY2RNF4NGN2IURPBGYUZ7PGYOYB" ],
+ "backups": [
+ {
+ "expect": {
+ "cipher": {
+ "iv": "ZGVmZ2hpamtsbW5v",
+ "name": "AES-256-GCM"
+ },
+ "data": "2w3i7Wa4kjB0nacZ4_8kxV0i8Y_I9Zont9uFpCu8I3zKs3rwJrTgQRubHAQkO4lg",
+ "document": {
+ "devices": [
+ {
+ "device": "pyr:hvabpq7iioevvevxbktu2g36xsojqlgpf3cjndgazvk7ckxumygf2lab",
+ "expires": 1793086400,
+ "issued": 1792999900,
+ "label": "browser",
+ "may": [
+ "ask",
+ "chat"
+ ],
+ "pyrtan-device": 1,
+ "root": "pyr:25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenaooab",
+ "sig": "arxqTAmJMOLXe5n3Q6MXMSZsWI_ym_k7fgEpAAR1Re0xmpiPX5FARkW82tGipVkRDhB15ZNCYUlHNSH6i0bUCg"
+ }
+ ],
+ "id": "pyr:25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenaooab",
+ "kind": "person",
+ "pyrtan-identity": 1,
+ "revoked": [
+ "pyr:h3rkrjzihszp24ujipnkcj7pbhsigby2rnf4ngn2iurpbgyuz7pgyoyb"
+ ],
+ "routes": [
+ "hub.example"
+ ],
+ "sig": "lrOT3YST22ZKxAragEUIeETBWexBcrQGXo3q-4w1KgH6e91hminoPmY2nJopC_4ED8vPi_h2AR9FBn1htbvCAQ",
+ "updated": 1793000000
+ },
+ "id": "pyr:25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenaooab",
+ "kdf": {
+ "iterations": 100000,
+ "name": "PBKDF2-SHA256",
+ "salt": "AAECAwQFBgcICQoLDA0ODw"
+ },
+ "made": 1793000000,
+ "pyrtan-key-backup": 1
+ },
+ "iterations": 100000,
+ "iv": "6465666768696a6b6c6d6e6f",
+ "made": 1793000000,
+ "passphrase": "correct horse battery staple é",
+ "public": "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",
+ "salt": "000102030405060708090a0b0c0d0e0f",
+ "seed": "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"
+ }
+ ],
"canonical": [ { "canonical": "null",
@@ -76,6 +130,154 @@
} } ],
+ "documents": [
+ {
+ "expect": {
+ "devices": [
+ {
+ "device": "pyr:hvabpq7iioevvevxbktu2g36xsojqlgpf3cjndgazvk7ckxumygf2lab",
+ "expires": 1793086400,
+ "issued": 1792999900,
+ "label": "browser",
+ "may": [
+ "ask",
+ "chat"
+ ],
+ "pyrtan-device": 1,
+ "root": "pyr:25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenaooab",
+ "sig": "arxqTAmJMOLXe5n3Q6MXMSZsWI_ym_k7fgEpAAR1Re0xmpiPX5FARkW82tGipVkRDhB15ZNCYUlHNSH6i0bUCg"
+ }
+ ],
+ "id": "pyr:25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenaooab",
+ "kind": "person",
+ "pyrtan-identity": 1,
+ "revoked": [
+ "pyr:h3rkrjzihszp24ujipnkcj7pbhsigby2rnf4ngn2iurpbgyuz7pgyoyb"
+ ],
+ "routes": [
+ "hub.example"
+ ],
+ "sig": "lrOT3YST22ZKxAragEUIeETBWexBcrQGXo3q-4w1KgH6e91hminoPmY2nJopC_4ED8vPi_h2AR9FBn1htbvCAQ",
+ "updated": 1793000000
+ },
+ "kind": "identity",
+ "make": {
+ "devices": [
+ {
+ "device": "pyr:hvabpq7iioevvevxbktu2g36xsojqlgpf3cjndgazvk7ckxumygf2lab",
+ "expires": 1793086400,
+ "issued": 1792999900,
+ "label": "browser",
+ "may": [
+ "ask",
+ "chat"
+ ],
+ "pyrtan-device": 1,
+ "root": "pyr:25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenaooab",
+ "sig": "arxqTAmJMOLXe5n3Q6MXMSZsWI_ym_k7fgEpAAR1Re0xmpiPX5FARkW82tGipVkRDhB15ZNCYUlHNSH6i0bUCg"
+ }
+ ],
+ "revoked": [
+ "pyr:h3rkrjzihszp24ujipnkcj7pbhsigby2rnf4ngn2iurpbgyuz7pgyoyb"
+ ],
+ "routes": [
+ "hub.example"
+ ],
+ "updated": 1793000000
+ },
+ "public": "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",
+ "seed": "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"
+ },
+ {
+ "expect": {
+ "device": "pyr:hvabpq7iioevvevxbktu2g36xsojqlgpf3cjndgazvk7ckxumygf2lab",
+ "expires": 1793086400,
+ "issued": 1792999900,
+ "label": "browser",
+ "may": [
+ "ask",
+ "chat"
+ ],
+ "pyrtan-device": 1,
+ "root": "pyr:25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenaooab",
+ "sig": "arxqTAmJMOLXe5n3Q6MXMSZsWI_ym_k7fgEpAAR1Re0xmpiPX5FARkW82tGipVkRDhB15ZNCYUlHNSH6i0bUCg"
+ },
+ "kind": "device",
+ "make": {
+ "device": "pyr:hvabpq7iioevvevxbktu2g36xsojqlgpf3cjndgazvk7ckxumygf2lab",
+ "expires": 1793086400,
+ "issued": 1792999900,
+ "label": "browser",
+ "may": [
+ "ask",
+ "chat"
+ ]
+ },
+ "public": "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",
+ "seed": "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"
+ },
+ {
+ "expect": {
+ "domain": "example.org",
+ "github": 42,
+ "id": "pyr:25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenaooab",
+ "issued": 1793000000,
+ "name": "alice",
+ "pyrtan-name": 1,
+ "sig": "8YJ06frvbX95dgpHib2jrIKGEXOAcPHgPfKJPVPHR1_nMSnce65VXVv5KNiAqIrJMGpzSHqXWSfBzAq8EQlxAQ"
+ },
+ "kind": "name",
+ "make": {
+ "extra": {
+ "github": 42
+ },
+ "issued": 1793000000,
+ "name": "Alice@Example.org"
+ },
+ "public": "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",
+ "seed": "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"
+ },
+ {
+ "expect": {
+ "domain": "example.org",
+ "handover": {
+ "id": "pyr:25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenaooab",
+ "sig": "lIIccU9haQd4hSCS9X0JQyhJjEdnaE-2mqsOT0-HpmPTjFkQBDx_i5p9KBEbGmk0PFgolqw5UbMvXgraRKbuDQ"
+ },
+ "id": "pyr:hvabpq7iioevvevxbktu2g36xsojqlgpf3cjndgazvk7ckxumygf2lab",
+ "issued": 1793000000,
+ "name": "alice",
+ "pyrtan-name": 1,
+ "sig": "RtfCmU-f2AnmBQ9tVW1HPo-1RgwOUIRVk-DKrIA9VE_8iDmtHHE53TFtgUS-Ol_HUPqUcPxQSneJ0keEWVfRAw"
+ },
+ "from_public": "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",
+ "from_seed": "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60",
+ "kind": "handover",
+ "make": {
+ "issued": 1793000000,
+ "name": "alice@example.org"
+ },
+ "public": "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c",
+ "seed": "4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb"
+ },
+ {
+ "expect": {
+ "domain": "example.org",
+ "id": "pyr:25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenaooab",
+ "issued": 1793000000,
+ "name": "alice",
+ "pyrtan-name-release": 1,
+ "sig": "qz5VZ2Fjp36NCijTxhxjn54Rh309qyNStpvF19bw_UutxEF-QhkOWwNeEWjoQzlkEq5HNahgzPhBA0IHnd3XAQ"
+ },
+ "kind": "release",
+ "make": {
+ "issued": 1793000000,
+ "name": "alice@example.org"
+ },
+ "public": "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",
+ "seed": "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"
+ }
+ ],
"ids": [ { "id": "pyr:25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenaooab", diff --git a/tests/conformance/make_identity_vectors.py b/tests/conformance/make_identity_vectors.py index 135a5bf..f17969b 100644 --- a/tests/conformance/make_identity_vectors.py +++ b/tests/conformance/make_identity_vectors.py
@@ -56,6 +56,21 @@ def build() -> dict:
"body": body, "note": note_for, "message": identity.request_message(audience, method, path, str(DATE), body).decode("utf-8"), "headers": identity.sign_request(key, audience, method, path, body, note=note_for, date=DATE), })
+ # What a root signs, at fixed times: identical bytes from both, Ed25519 being deterministic.
+ other = keys[2]
+ doc = identity.make_identity(root, routes=["hub.example"], devices=[note], revoked=[other.id], updated=DATE)
+ documents = [
+ {"kind": "identity", "seed": SEEDS[0], "public": public[0], "make": {"routes": ["hub.example"], "devices": [note], "revoked": [other.id], "updated": DATE}, "expect": doc},
+ {"kind": "device", "seed": SEEDS[0], "public": public[0], "make": {"device": phone.id, "may": ["ask", "chat"], "label": "browser", "issued": DATE - 100, "expires": DATE + 86400}, "expect": note},
+ {"kind": "name", "seed": SEEDS[0], "public": public[0], "make": {"name": "Alice@Example.org", "issued": DATE, "extra": {"github": 42}}, "expect": identity.make_name(root, "Alice@Example.org", issued=DATE, github=42)},
+ {"kind": "handover", "seed": SEEDS[1], "public": public[1], "from_seed": SEEDS[0], "from_public": public[0], "make": {"name": "alice@example.org", "issued": DATE},
+ "expect": identity.make_name(phone, "alice@example.org", issued=DATE, handover_from=root)},
+ {"kind": "release", "seed": SEEDS[0], "public": public[0], "make": {"name": "alice@example.org", "issued": DATE}, "expect": identity.make_release(root, "alice@example.org", issued=DATE)},
+ ]
+ # A backup at the fewest rounds a backup may claim, so the test is quick; the format is the same at 600,000.
+ salt, iv = bytes(range(16)), bytes(range(100, 112))
+ backups = [{"seed": SEEDS[0], "public": public[0], "passphrase": "correct horse battery staple é", "salt": salt.hex(), "iv": iv.hex(), "iterations": identity.MIN_ITERATIONS, "made": DATE,
+ "expect": identity.make_backup(root, "correct horse battery staple é", document=doc, salt=salt, iv=iv, iterations=identity.MIN_ITERATIONS, made=DATE)}]
samples = [b"", b"abc", bytes(range(256)), b"x" * 135, b"x" * 136, b"x" * 137] return { "sha3_256": [{"in": s.hex(), "out": hashlib.sha3_256(s).hexdigest()} for s in samples],
@@ -65,6 +80,8 @@ def build() -> dict:
"canonical": [{"value": v, "canonical": canonical(v).decode("utf-8")} for v in VALUES], "requests": requests, "note": note,
+ "documents": documents,
+ "backups": backups,
}