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 = "", issuissued = 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, 16const 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,}