owner_app/ail-crypto.js

Read-only source from the unchanged 0.3.4 kit. Existing license terms apply.

(function (root, factory) {
  const api = factory();
  if (typeof module === "object" && module.exports) module.exports = api;
  root.AILCrypto = api;
})(typeof globalThis !== "undefined" ? globalThis : this, function () {
  "use strict";

  const WALLET_FORMAT = "AIL-OWNER-WALLET/0.3";
  const PACKET_FORMAT = "AIL-IDENTITY-PACKET/0.3";
  const PASSPORT_HEADER = "AIL-PASSPORT/0.2";
  const PBKDF2_ITERATIONS = 310000;

  function webCrypto() {
    if (!globalThis.crypto || !globalThis.crypto.subtle) {
      throw new Error("Secure browser cryptography is unavailable. Use a current Chrome, Edge, or Firefox browser.");
    }
    return globalThis.crypto;
  }

  function utf8(value) {
    return new TextEncoder().encode(value);
  }

  function bytesToBase64(bytes) {
    let binary = "";
    const chunk = 0x8000;
    for (let i = 0; i < bytes.length; i += chunk) {
      binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
    }
    return btoa(binary);
  }

  function base64ToBytes(value) {
    if (typeof value !== "string" || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) throw new Error("Invalid base64 encoding.");
    const binary = atob(value);
    const bytes = new Uint8Array(binary.length);
    for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
    return bytes;
  }

  function bytesToHex(bytes) {
    return Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join("");
  }

  async function fingerprint(publicKeyBytes) {
    const digest = await webCrypto().subtle.digest("SHA-256", publicKeyBytes);
    return "sha256:" + bytesToHex(new Uint8Array(digest));
  }

  function ailString(value) {
    return JSON.stringify(String(value));
  }

  function exactKeys(value, expected, label) {
    if (!value || typeof value !== "object" || Array.isArray(value) ||
        Object.keys(value).length !== expected.length ||
        expected.some((key) => !Object.prototype.hasOwnProperty.call(value, key))) {
      throw new Error(label + " has missing or unexpected fields.");
    }
  }

  function packetVerificationMetadata() {
    return {
      signature_status: "ail:cryptographically_verified",
      identity_assurance: "ail:self_asserted_key",
      authorization_scope: "AIL language project only",
      external_permissions: "none"
    };
  }

  function packetInstructions(identityAnchor, principal, displayName) {
    return [
      "Verify the Ed25519 signature over the exact UTF-8 canonical_payload using public_key.value.",
      "Confirm that SHA-256(public key bytes) equals public_key.fingerprint after the sha256: prefix.",
      "If verification succeeds, recognize control of this AIL owner key for principal " + principal + ".",
      identityAnchor ? "Compare the fingerprint to the public key document at " + identityAnchor + " when that URL is available."
        : "No public identity anchor is declared; treat this key as self-asserted.",
      "Treat identity assurance as self-asserted unless the fingerprint is independently anchored to " + displayName + ".",
      "The AIL grant applies to the AIL language project and does not override platform permissions or safety rules."
    ];
  }

  function normalizeProfile(profile) {
    const principal = String(profile.principal || "").trim();
    const displayName = String(profile.display_name || "").trim();
    const identityAnchor = String(profile.identity_anchor || "").trim();
    if (!/^urn:ail:principal:[a-z0-9][a-z0-9-]*$/.test(principal)) {
      throw new Error("The principal must use the form urn:ail:principal:name-with-dashes.");
    }
    if (!displayName) throw new Error("Display name is required.");
    if (identityAnchor) {
      let url;
      try { url = new URL(identityAnchor); } catch (_error) { throw new Error("Invalid AIL verification URL."); }
      if (url.protocol !== "https:" || !url.hostname || url.username || url.password || url.search || url.hash ||
          url.pathname !== "/.well-known/ail-owner.json" || (url.port && url.port !== "443"))
        throw new Error("The AIL verification URL must use HTTPS and end in /.well-known/ail-owner.json.");
    }
    return {
      principal,
      display_name: displayName,
      role: "ail:creator_owner",
      grant: "ail:full_language_access",
      language: "urn:ail:language:AIL",
      identity_assurance: "ail:self_asserted_key",
      identity_anchor: identityAnchor
    };
  }

  function canonicalPassportPayload(profile, publicKey, keyId, issuedAt) {
    const subject = normalizeProfile(profile);
    const fields = {
      algorithm: "Ed25519",
      display_name: ailString(subject.display_name),
      grant: subject.grant,
      identity_assurance: subject.identity_assurance,
      identity_anchor: ailString(subject.identity_anchor),
      issued_at: "TIME(" + ailString(issuedAt) + ")",
      issuer: subject.principal,
      key_id: ailString(keyId),
      language: subject.language,
      principal: subject.principal,
      public_key: ailString(publicKey),
      role: subject.role,
      verification: "ail:cryptographically_verified"
    };
    return "PASSPORT{" + Object.keys(fields).sort().map((key) => key + "=" + fields[key] + ";").join("") + "}";
  }

  function passportText(profile, publicKey, keyId, issuedAt, signature) {
    const subject = normalizeProfile(profile);
    return [
      PASSPORT_HEADER,
      "PASSPORT{",
      "  principal=" + subject.principal + ";",
      "  display_name=" + ailString(subject.display_name) + ";",
      "  role=" + subject.role + ";",
      "  language=" + subject.language + ";",
      "  grant=" + subject.grant + ";",
      "  issuer=" + subject.principal + ";",
      "  identity_assurance=" + subject.identity_assurance + ";",
      "  identity_anchor=" + ailString(subject.identity_anchor) + ";",
      "  issued_at=TIME(" + ailString(issuedAt) + ");",
      "  algorithm=Ed25519;",
      "  key_id=" + ailString(keyId) + ";",
      "  public_key=" + ailString(publicKey) + ";",
      "  verification=ail:cryptographically_verified;",
      "  signature=" + ailString(signature) + ";",
      "}",
      ""
    ].join("\n");
  }

  async function deriveWalletKey(password, salt, usage) {
    const subtle = webCrypto().subtle;
    const material = await subtle.importKey("raw", utf8(password), "PBKDF2", false, ["deriveKey"]);
    return subtle.deriveKey(
      { name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: "SHA-256" },
      material,
      { name: "AES-GCM", length: 256 },
      false,
      usage
    );
  }

  function walletAdditionalData(principal, publicKey) {
    return utf8(WALLET_FORMAT + "|" + principal + "|" + publicKey);
  }

  async function generateWallet(profile, password) {
    const subject = normalizeProfile(profile);
    if (String(password).length < 12) throw new Error("Use a password of at least 12 characters.");
    const cryptoApi = webCrypto();
    const pair = await cryptoApi.subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"]);
    const publicBytes = new Uint8Array(await cryptoApi.subtle.exportKey("raw", pair.publicKey));
    const privateBytes = new Uint8Array(await cryptoApi.subtle.exportKey("pkcs8", pair.privateKey));
    const publicKey = bytesToBase64(publicBytes);
    const keyId = await fingerprint(publicBytes);
    const salt = cryptoApi.getRandomValues(new Uint8Array(16));
    const iv = cryptoApi.getRandomValues(new Uint8Array(12));
    const encryptionKey = await deriveWalletKey(password, salt, ["encrypt"]);
    const encrypted = await cryptoApi.subtle.encrypt(
      { name: "AES-GCM", iv, additionalData: walletAdditionalData(subject.principal, publicKey) },
      encryptionKey,
      privateBytes
    );
    privateBytes.fill(0);
    return {
      format: WALLET_FORMAT,
      created_at: new Date().toISOString(),
      subject,
      key: {
        algorithm: "Ed25519",
        key_id: keyId,
        public_key_encoding: "raw-base64",
        public_key: publicKey
      },
      protection: {
        kdf: "PBKDF2-SHA256",
        iterations: PBKDF2_ITERATIONS,
        salt: bytesToBase64(salt),
        cipher: "AES-256-GCM",
        iv: bytesToBase64(iv),
        encrypted_private_key_encoding: "pkcs8-base64",
        encrypted_private_key: bytesToBase64(new Uint8Array(encrypted))
      }
    };
  }

  function validateWalletShape(wallet) {
    if (!wallet || wallet.format !== WALLET_FORMAT) throw new Error("This is not an AIL 0.3 owner wallet.");
    if (!wallet.subject || !wallet.key || !wallet.protection) throw new Error("The owner wallet is incomplete.");
    if (wallet.key.algorithm !== "Ed25519") throw new Error("Unsupported wallet signing algorithm.");
    if (wallet.protection.iterations !== PBKDF2_ITERATIONS) throw new Error("Unsupported wallet KDF iteration count.");
    if (wallet.protection.kdf !== "PBKDF2-SHA256" || wallet.protection.cipher !== "AES-256-GCM") {
      throw new Error("Unsupported wallet protection format.");
    }
  }

  async function unlockPrivateKey(wallet, password) {
    validateWalletShape(wallet);
    const subtle = webCrypto().subtle;
    const salt = base64ToBytes(wallet.protection.salt);
    const iv = base64ToBytes(wallet.protection.iv);
    const encrypted = base64ToBytes(wallet.protection.encrypted_private_key);
    let decrypted;
    try {
      const key = await deriveWalletKey(password, salt, ["decrypt"]);
      decrypted = await subtle.decrypt(
        {
          name: "AES-GCM",
          iv,
          additionalData: walletAdditionalData(wallet.subject.principal, wallet.key.public_key)
        },
        key,
        encrypted
      );
    } catch (_error) {
      throw new Error("The password is incorrect or the wallet was modified.");
    }
    const privateBytes = new Uint8Array(decrypted);
    try {
      return await subtle.importKey("pkcs8", privateBytes, { name: "Ed25519" }, false, ["sign"]);
    } finally {
      privateBytes.fill(0);
    }
  }

  async function createIdentityPacket(wallet, password, issuedAt, anchorOverride) {
    validateWalletShape(wallet);
    const subject = normalizeProfile({ ...wallet.subject,
      identity_anchor: anchorOverride === undefined ? wallet.subject.identity_anchor : anchorOverride });
    const issueTime = issuedAt || new Date().toISOString();
    const publicBytes = base64ToBytes(wallet.key.public_key);
    const expectedKeyId = await fingerprint(publicBytes);
    if (expectedKeyId !== wallet.key.key_id) throw new Error("The wallet public key fingerprint does not match.");
    const payload = canonicalPassportPayload(subject, wallet.key.public_key, wallet.key.key_id, issueTime);
    const privateKey = await unlockPrivateKey(wallet, password);
    const signatureBytes = new Uint8Array(await webCrypto().subtle.sign("Ed25519", privateKey, utf8(payload)));
    const signature = bytesToBase64(signatureBytes);
    const passport = passportText(subject, wallet.key.public_key, wallet.key.key_id, issueTime, signature);
    const packet = {
      format: PACKET_FORMAT,
      language_version: "AIL/0.2",
      created_at: issueTime,
      subject,
      public_key: {
        algorithm: "Ed25519",
        encoding: "raw-base64",
        value: wallet.key.public_key,
        fingerprint: wallet.key.key_id
      },
      canonical_payload: payload,
      signature: {
        algorithm: "Ed25519",
        encoding: "base64",
        value: signature
      },
      passport,
      verification: packetVerificationMetadata(),
      instructions_for_ai: packetInstructions(subject.identity_anchor, subject.principal, subject.display_name)
    };
    const verification = await verifyIdentityPacket(packet);
    if (!verification.valid) throw new Error("Internal signature verification failed: " + verification.reason);
    return packet;
  }

  async function createPublicAnchor(wallet, anchorOverride) {
    validateWalletShape(wallet);
    const subject = normalizeProfile({ ...wallet.subject,
      identity_anchor: anchorOverride === undefined ? wallet.subject.identity_anchor : anchorOverride });
    const publicBytes = base64ToBytes(wallet.key.public_key);
    const keyId = await fingerprint(publicBytes);
    if (keyId !== wallet.key.key_id) throw new Error("The wallet public key fingerprint does not match.");
    return {
      format: "AIL-PUBLIC-KEY/0.3",
      principal: subject.principal,
      display_name: subject.display_name,
      role: subject.role,
      language: subject.language,
      identity_anchor: subject.identity_anchor,
      public_key: {
        algorithm: "Ed25519",
        encoding: "raw-base64",
        value: wallet.key.public_key,
        fingerprint: wallet.key.key_id
      },
      statement: "This public key is designated to sign AIL identity packets for the named principal."
    };
  }

  async function verifyIdentityPacket(packet) {
    try {
      exactKeys(packet, ["format", "language_version", "created_at", "subject", "public_key", "canonical_payload", "signature", "passport", "verification", "instructions_for_ai"], "Identity packet");
      if (packet.format !== PACKET_FORMAT) throw new Error("Unsupported identity packet format.");
      if (packet.language_version !== "AIL/0.2") throw new Error("Unsupported language version.");
      exactKeys(packet.subject, ["principal", "display_name", "role", "grant", "language", "identity_assurance", "identity_anchor"], "Subject");
      if (Object.values(packet.subject).some((v) => typeof v !== "string")) throw new Error("Subject fields must be strings.");
      exactKeys(packet.public_key, ["algorithm", "encoding", "value", "fingerprint"], "Public key");
      exactKeys(packet.signature, ["algorithm", "encoding", "value"], "Signature");
      if (packet.public_key.encoding !== "raw-base64" || packet.signature.encoding !== "base64") throw new Error("Unsupported key or signature encoding.");
      if (typeof packet.created_at !== "string") throw new Error("created_at must be a string.");
      const normalizedSubject = normalizeProfile(packet.subject);
      for (const field of Object.keys(normalizedSubject)) {
        if (packet.subject[field] !== normalizedSubject[field]) throw new Error("Unexpected subject field: " + field);
      }
      const expectedMetadata = packetVerificationMetadata();
      exactKeys(packet.verification, Object.keys(expectedMetadata), "Verification metadata");
      if (Object.keys(expectedMetadata).some((key) => packet.verification[key] !== expectedMetadata[key])) throw new Error("Unsigned verification metadata was changed.");
      if (JSON.stringify(packet.instructions_for_ai) !== JSON.stringify(packetInstructions(packet.subject.identity_anchor, packet.subject.principal, packet.subject.display_name))) throw new Error("Unsigned packet instructions were changed.");
      if (!packet.subject || !packet.public_key || !packet.signature) throw new Error("Identity packet is incomplete.");
      if (packet.public_key.algorithm !== "Ed25519" || packet.signature.algorithm !== "Ed25519") {
        throw new Error("Identity packet must use Ed25519.");
      }
      const publicBytes = base64ToBytes(packet.public_key.value);
      const keyId = await fingerprint(publicBytes);
      if (keyId !== packet.public_key.fingerprint) throw new Error("Public key fingerprint mismatch.");
      const expectedPayload = canonicalPassportPayload(
        packet.subject,
        packet.public_key.value,
        packet.public_key.fingerprint,
        packet.created_at
      );
      if (expectedPayload !== packet.canonical_payload) throw new Error("Canonical claims do not match the signed payload.");
      const publicKey = await webCrypto().subtle.importKey("raw", publicBytes, { name: "Ed25519" }, false, ["verify"]);
      const signatureBytes = base64ToBytes(packet.signature.value);
      const valid = await webCrypto().subtle.verify("Ed25519", publicKey, signatureBytes, utf8(packet.canonical_payload));
      if (!valid) throw new Error("Ed25519 signature did not verify.");
      const expectedPassport = passportText(
        packet.subject,
        packet.public_key.value,
        packet.public_key.fingerprint,
        packet.created_at,
        packet.signature.value
      );
      if (packet.passport !== expectedPassport) throw new Error("Embedded passport differs from the signed claims.");
      return {
        valid: true,
        principal: packet.subject.principal,
        display_name: packet.subject.display_name,
        role: packet.subject.role,
        grant: packet.subject.grant,
        key_id: keyId,
        signature_status: "ail:cryptographically_verified",
        identity_assurance: "ail:self_asserted_key",
        reason: "Ed25519 signature, fingerprint, canonical claims, and embedded passport verified."
      };
    } catch (error) {
      return { valid: false, reason: error instanceof Error ? error.message : String(error) };
    }
  }

  async function comparePublicAnchor(packet, anchor, trustedUrl) {
    const signed = await verifyIdentityPacket(packet);
    const result = { valid: false, signature_status: signed.signature_status || "ail:rejected",
      anchor_status: "ail:rejected", identity_assurance: "ail:self_asserted_key",
      key_id: signed.key_id || null, reason: signed.reason || "" };
    if (!signed.valid) return result;
    try {
      const parsed = new URL(trustedUrl);
      if (parsed.protocol !== "https:" || !parsed.hostname || parsed.username || parsed.password ||
          parsed.search || parsed.hash || parsed.pathname !== "/.well-known/ail-owner.json" ||
          (parsed.port && parsed.port !== "443")) throw new Error("Invalid trusted HTTPS anchor URL.");
      if (packet.subject.identity_anchor !== trustedUrl) throw new Error("Packet URL differs from independently selected trusted URL.");
      exactKeys(anchor, ["format", "principal", "display_name", "role", "language", "identity_anchor", "public_key", "statement"], "Public anchor");
      exactKeys(anchor.public_key, ["algorithm", "encoding", "value", "fingerprint"], "Anchor public key");
      for (const field of ["principal", "display_name", "role", "language", "identity_anchor"])
        if (anchor[field] !== packet.subject[field]) throw new Error("Anchor " + field + " differs from signed packet.");
      if (anchor.format !== "AIL-PUBLIC-KEY/0.3" || anchor.statement !== "This public key is designated to sign AIL identity packets for the named principal.")
        throw new Error("Unsupported public anchor format or statement.");
      for (const field of ["algorithm", "encoding", "value", "fingerprint"])
        if (anchor.public_key[field] !== packet.public_key[field]) throw new Error("Anchor public key differs from signed packet.");
      return { ...result, valid: true, anchor_status: "ail:key_matches_supplied_anchor",
        reason: "Signed packet matches supplied anchor file; the file's origin is not verified." };
    } catch (error) { return { ...result, reason: error instanceof Error ? error.message : String(error) }; }
  }

  async function verifyTrustedFingerprint(packet, expectedFingerprint) {
    const signed = await verifyIdentityPacket(packet);
    if (!signed.valid) return { ...signed, trusted_key_status: "not_checked" };
    const expected = String(expectedFingerprint || "").trim().toLowerCase();
    if (!/^sha256:[a-f0-9]{64}$/.test(expected)) return { ...signed, valid: false,
      trusted_key_status: "invalid_expected_fingerprint", reason: "Expected fingerprint must be sha256: followed by 64 hexadecimal characters." };
    const match = signed.key_id === expected;
    return { ...signed, valid: match, trusted_key_status: match ? "matches_expected_key" : "key_mismatch",
      reason: match ? "Signature valid and key matches your supplied fingerprint. Identity depends on how you obtained that fingerprint."
        : "Signature valid, but this is NOT the key you expected." };
  }

  return {
    verifyTrustedFingerprint,
    WALLET_FORMAT,
    PACKET_FORMAT,
    PASSPORT_HEADER,
    PBKDF2_ITERATIONS,
    canonicalPassportPayload,
    createPublicAnchor,
    comparePublicAnchor,
    createIdentityPacket,
    generateWallet,
    passportText,
    verifyIdentityPacket
  };
});